@hraness/ghostget 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +458 -0
- package/LICENSE +21 -0
- package/README.md +1359 -0
- package/bunfig.toml +2 -0
- package/dist/apple-photos-client.js +704 -0
- package/dist/beeper-client.js +4602 -0
- package/dist/client.js +1605 -0
- package/dist/index-26yq8q16.js +31 -0
- package/dist/index-9t13b4k7.js +508 -0
- package/dist/index-axwm9b3v.js +5 -0
- package/dist/index-gwk7rbyj.js +30 -0
- package/dist/index.js +701 -0
- package/dist/messaging.js +1130 -0
- package/dist/omni-client.js +1406 -0
- package/dist/whatsapp-client.js +355 -0
- package/docs/imessage-direct-provider.md +106 -0
- package/docs/rental-listings.md +94 -0
- package/package.json +600 -0
- package/skills/ghostget/SKILL.md +251 -0
- package/skills/ghostget/agents/openai.yaml +4 -0
- package/skills/ghostget/references/adapter-contract.md +181 -0
- package/skills/ghostget/references/apple-photos.md +115 -0
- package/skills/ghostget/references/article-drafts.md +235 -0
- package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
- package/skills/ghostget/references/cross-posting-video.md +139 -0
- package/skills/ghostget/references/cross-posting.md +82 -0
- package/skills/ghostget/references/derivation.md +390 -0
- package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
- package/skills/ghostget/references/install.md +66 -0
- package/skills/ghostget/references/linkedin-adapter.md +442 -0
- package/skills/ghostget/references/messaging.md +168 -0
- package/skills/ghostget/references/meta-comet-contract.md +153 -0
- package/skills/ghostget/references/platform-patterns.md +122 -0
- package/skills/ghostget/references/provider-plugins.md +496 -0
- package/skills/ghostget/references/reddit-flair.md +78 -0
- package/skills/ghostget/references/safety-and-state.md +214 -0
- package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
- package/skills/ghostget/references/social-platform-routing.md +66 -0
- package/skills/ghostget/references/social-profile-stats.md +195 -0
- package/skills/ghostget/references/social-video-platform-routing.md +89 -0
- package/skills/ghostget/references/whatsapp-adapter.md +101 -0
- package/skills/ghostget/references/x-adapter.md +332 -0
- package/skills/ghostget/references/x-ai-disclosure.md +71 -0
- package/src/apple-photos-cli.ts +639 -0
- package/src/apple-photos-client-types.ts +168 -0
- package/src/apple-photos-client.ts +201 -0
- package/src/apple-photos-contact-evidence.ts +838 -0
- package/src/apple-photos-local-source.ts +1131 -0
- package/src/args.ts +2053 -0
- package/src/article-draft-document.ts +417 -0
- package/src/article-draft-embeds.ts +123 -0
- package/src/article-draft-images.ts +214 -0
- package/src/assets/adapter-template/wrench-adapter.json +40 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
- package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
- package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
- package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
- package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
- package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
- package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
- package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
- package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
- package/src/assets/adapters/x/wrench-adapter.json +749 -0
- package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
- package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
- package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
- package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
- package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
- package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
- package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
- package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
- package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
- package/src/assets.d.ts +4 -0
- package/src/auth.ts +1157 -0
- package/src/beeper-client-types.ts +169 -0
- package/src/beeper-client.ts +250 -0
- package/src/beeper-contact-interactions-cli.ts +180 -0
- package/src/beeper-contact-interactions.ts +1612 -0
- package/src/beeper-message-bundle-v1.ts +257 -0
- package/src/beeper-message-like-me-cli.ts +84 -0
- package/src/beeper-message-like-me-export.ts +2274 -0
- package/src/beeper-message-like-me-recovery.ts +931 -0
- package/src/beeper-message-like-me-source.ts +4034 -0
- package/src/browser-admission.ts +928 -0
- package/src/browser-snapshots.ts +260 -0
- package/src/browser.ts +3873 -0
- package/src/canonical-json.ts +60 -0
- package/src/catalog-cli.ts +638 -0
- package/src/cli.ts +208 -0
- package/src/client-types.ts +301 -0
- package/src/client.ts +2726 -0
- package/src/confirmed-write-failure.ts +44 -0
- package/src/confirmed-write-model.ts +72 -0
- package/src/confirmed-write-platform.ts +1055 -0
- package/src/confirmed-write-program.ts +224 -0
- package/src/confirmed-write-runtime.ts +16 -0
- package/src/cursor-token.ts +228 -0
- package/src/derivation-dnr-readiness.ts +367 -0
- package/src/derivation-file-chooser.ts +384 -0
- package/src/derivation-network-boundary.ts +835 -0
- package/src/derivation-network-guard.ts +782 -0
- package/src/derivation-network-proxy-helper.ts +440 -0
- package/src/derivation-network-proxy.ts +385 -0
- package/src/derive-command-helper.ts +693 -0
- package/src/derive-fixtures.ts +343 -0
- package/src/derive-review.ts +980 -0
- package/src/derive.ts +4754 -0
- package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
- package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
- package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
- package/src/ghostget.ts +3672 -0
- package/src/gmail-capture.ts +1244 -0
- package/src/har-internal.ts +1242 -0
- package/src/har.ts +743 -0
- package/src/index.ts +218 -0
- package/src/invocation-read-platform.ts +55 -0
- package/src/invocation-read-program.ts +93 -0
- package/src/invocation-read-runtime.ts +11 -0
- package/src/linked-device-lifecycle-admission.ts +450 -0
- package/src/linked-device-lifecycle-journal.ts +1560 -0
- package/src/linked-device-lifecycle-runtime.ts +1602 -0
- package/src/local-cli-admission.ts +135 -0
- package/src/local-cli-contracts.ts +271 -0
- package/src/local-cli-execution.ts +98 -0
- package/src/local-cli-surface-contract.ts +1510 -0
- package/src/local-cli-tool-identity.ts +336 -0
- package/src/media/archive.ts +2325 -0
- package/src/media/args.ts +303 -0
- package/src/media/cli.ts +287 -0
- package/src/media/doctor.ts +475 -0
- package/src/media/ffmpeg.ts +1330 -0
- package/src/media/http-capture.ts +711 -0
- package/src/media/http-probe.ts +593 -0
- package/src/media/http.ts +387 -0
- package/src/media/index.ts +10 -0
- package/src/media/local-transcription.ts +613 -0
- package/src/media/lock.ts +321 -0
- package/src/media/manifest.ts +1712 -0
- package/src/media/metadata.ts +906 -0
- package/src/media/process.ts +723 -0
- package/src/media/revision.ts +166 -0
- package/src/media/runtime-closure.ts +1063 -0
- package/src/media/source-router.ts +187 -0
- package/src/media/transcriber-config.ts +1122 -0
- package/src/media/transcript-persistence-model.ts +15 -0
- package/src/media/transcript-persistence-platform.ts +89 -0
- package/src/media/transcript-persistence-program.ts +39 -0
- package/src/media/transcript-persistence-runtime.ts +24 -0
- package/src/media/transcript.ts +616 -0
- package/src/media/utf8-order.ts +30 -0
- package/src/media/whisper-cpp.ts +464 -0
- package/src/media/whisper-language.ts +58 -0
- package/src/media/yt-dlp.ts +439 -0
- package/src/message-like-me-agentic-messaging.ts +882 -0
- package/src/messaging-action-store.ts +793 -0
- package/src/messaging-runtime.ts +2136 -0
- package/src/messaging-store.ts +539 -0
- package/src/messaging-types.ts +1663 -0
- package/src/messaging.ts +334 -0
- package/src/model.ts +2671 -0
- package/src/oauth-google.ts +888 -0
- package/src/omni-client-types.ts +306 -0
- package/src/omni-client.ts +2225 -0
- package/src/omni-limits.ts +34 -0
- package/src/omni-model.ts +1614 -0
- package/src/omni-request.ts +379 -0
- package/src/omni-runtime.ts +1632 -0
- package/src/operation-deadline.ts +189 -0
- package/src/path-helper.ts +1656 -0
- package/src/pinned-https.ts +194 -0
- package/src/plan-assets.ts +832 -0
- package/src/platform-catalog.ts +1832 -0
- package/src/plugins/beeper-linked-device/plugin.ts +310 -0
- package/src/plugins/bluesky-web/plugin.ts +178 -0
- package/src/plugins/clasificados-web/plugin.ts +66 -0
- package/src/plugins/github-web/plugin.ts +80 -0
- package/src/plugins/gmail-official/plugin.ts +110 -0
- package/src/plugins/hacker-news-web/plugin.ts +52 -0
- package/src/plugins/imessage-direct/plugin.ts +195 -0
- package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
- package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
- package/src/plugins/linkedin-official/plugin.ts +84 -0
- package/src/plugins/linkedin-web/plugin.ts +412 -0
- package/src/plugins/meta-web/plugin.ts +317 -0
- package/src/plugins/reddit-web/flair.ts +492 -0
- package/src/plugins/reddit-web/plugin.ts +477 -0
- package/src/plugins/substack-web/plugin.ts +124 -0
- package/src/plugins/tiktok-web/plugin.ts +67 -0
- package/src/plugins/twitch-web/plugin.ts +54 -0
- package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
- package/src/plugins/x-official/plugin.ts +64 -0
- package/src/plugins/x-web/plugin.ts +488 -0
- package/src/plugins/youtube-web/plugin.ts +101 -0
- package/src/portable-run-recovery.ts +466 -0
- package/src/process-identity.ts +501 -0
- package/src/profile-clone-helper.ts +104 -0
- package/src/provider-catalog-views.ts +51 -0
- package/src/provider-context.ts +69 -0
- package/src/provider-contract-definitions-x.ts +45 -0
- package/src/provider-contract-definitions.ts +948 -0
- package/src/provider-contract-input-gmail.ts +176 -0
- package/src/provider-contract-input-linkedin.ts +77 -0
- package/src/provider-contract-input-x.ts +304 -0
- package/src/provider-contract-planning.ts +23 -0
- package/src/provider-contract-semantic-identity.ts +54 -0
- package/src/provider-contracts.ts +251 -0
- package/src/provider-http.ts +723 -0
- package/src/provider-plugin-auth.ts +36 -0
- package/src/provider-plugin-builtins.ts +229 -0
- package/src/provider-plugin-cleanup-barrier.ts +229 -0
- package/src/provider-plugin-cleanup-execution.ts +79 -0
- package/src/provider-plugin-cleanup-resource.ts +470 -0
- package/src/provider-plugin-contract-identity.ts +707 -0
- package/src/provider-plugin-host.ts +1972 -0
- package/src/provider-plugin-identifiers.ts +60 -0
- package/src/provider-plugin-invocation-lease.ts +889 -0
- package/src/provider-plugin-lifecycle-kernel.ts +566 -0
- package/src/provider-plugin-lifecycle.ts +1455 -0
- package/src/provider-plugin-module-analysis.ts +336 -0
- package/src/provider-plugin-package.ts +2530 -0
- package/src/provider-plugin-portable-authority.ts +106 -0
- package/src/provider-plugin-portable-catalog.ts +94 -0
- package/src/provider-plugin-portable-identity.ts +315 -0
- package/src/provider-plugin-portable-runtime.ts +2057 -0
- package/src/provider-plugin-protocol.ts +1625 -0
- package/src/provider-plugin-registry.ts +3342 -0
- package/src/provider-plugin-store.ts +2155 -0
- package/src/provider-plugin-test-fixture.ts +5 -0
- package/src/provider-plugin.ts +4907 -0
- package/src/provider-plugins.generated.ts +43 -0
- package/src/provider-plugins.ts +8 -0
- package/src/provider-subject.ts +41 -0
- package/src/provider.ts +315 -0
- package/src/providers/beeper-local-runtime.ts +5426 -0
- package/src/providers/beeper-local.ts +2739 -0
- package/src/providers/beeper-messaging.ts +462 -0
- package/src/providers/beeper-omni.ts +1159 -0
- package/src/providers/bluesky-web-runtime.ts +2895 -0
- package/src/providers/bluesky-web.ts +1614 -0
- package/src/providers/clasificados-web-runtime.ts +255 -0
- package/src/providers/clasificados-web.ts +503 -0
- package/src/providers/contact-projection.ts +301 -0
- package/src/providers/github-read-model.ts +94 -0
- package/src/providers/github-read-platform.ts +165 -0
- package/src/providers/github-read-program.ts +97 -0
- package/src/providers/github-web-runtime.ts +320 -0
- package/src/providers/github-web.ts +403 -0
- package/src/providers/gmail-api.ts +2834 -0
- package/src/providers/gmail-omni.ts +830 -0
- package/src/providers/gmail.ts +1130 -0
- package/src/providers/hacker-news-web-runtime.ts +220 -0
- package/src/providers/hacker-news-web.ts +809 -0
- package/src/providers/imessage-direct-install.ts +354 -0
- package/src/providers/imessage-direct-messaging.ts +532 -0
- package/src/providers/imessage-direct-omni.ts +400 -0
- package/src/providers/imessage-direct-runtime.ts +1498 -0
- package/src/providers/imessage-direct.ts +351 -0
- package/src/providers/instagram-video-foundations.ts +913 -0
- package/src/providers/instagram-web-profile-browser.ts +600 -0
- package/src/providers/iso-bmff.ts +338 -0
- package/src/providers/linkedin-company-platform.ts +42 -0
- package/src/providers/linkedin-company-program.ts +88 -0
- package/src/providers/linkedin-contact-failure.ts +84 -0
- package/src/providers/linkedin-contact-platform.ts +45 -0
- package/src/providers/linkedin-contact-program.ts +91 -0
- package/src/providers/linkedin-profile-activity-failure.ts +76 -0
- package/src/providers/linkedin-profile-activity-platform.ts +41 -0
- package/src/providers/linkedin-profile-activity-program.ts +67 -0
- package/src/providers/linkedin-read-failure.ts +52 -0
- package/src/providers/linkedin-self-platform.ts +53 -0
- package/src/providers/linkedin-self-program.ts +122 -0
- package/src/providers/linkedin-web-article-browser.ts +794 -0
- package/src/providers/linkedin-web-bootstrap.ts +146 -0
- package/src/providers/linkedin-web-contact.ts +830 -0
- package/src/providers/linkedin-web-feed-browser.ts +832 -0
- package/src/providers/linkedin-web-feed.ts +864 -0
- package/src/providers/linkedin-web-post-browser.ts +653 -0
- package/src/providers/linkedin-web-profile-browser.ts +683 -0
- package/src/providers/linkedin-web-runtime.ts +2388 -0
- package/src/providers/linkedin-web.ts +3305 -0
- package/src/providers/linkedin.ts +1885 -0
- package/src/providers/meta-bootstrap.ts +1043 -0
- package/src/providers/meta-facebook-group.ts +690 -0
- package/src/providers/meta-marketplace-relay.ts +747 -0
- package/src/providers/meta-omni.ts +373 -0
- package/src/providers/meta-relay-bundle.ts +1049 -0
- package/src/providers/meta-web-descriptors.ts +1740 -0
- package/src/providers/meta-web-runtime.ts +3388 -0
- package/src/providers/meta-web.ts +2931 -0
- package/src/providers/read-failure.ts +139 -0
- package/src/providers/reddit-omni.ts +462 -0
- package/src/providers/reddit-web-runtime.ts +1929 -0
- package/src/providers/reddit-web.ts +1611 -0
- package/src/providers/rental-listings.ts +460 -0
- package/src/providers/substack-omni.ts +406 -0
- package/src/providers/substack-video-mp4.ts +33 -0
- package/src/providers/substack-web-runtime.ts +2812 -0
- package/src/providers/substack-web.ts +1502 -0
- package/src/providers/tiktok-video-mp4.ts +33 -0
- package/src/providers/tiktok-web-runtime.ts +770 -0
- package/src/providers/tiktok-web.ts +2786 -0
- package/src/providers/twitch-web-runtime.ts +226 -0
- package/src/providers/twitch-web.ts +274 -0
- package/src/providers/whatsapp-account-identity.ts +55 -0
- package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
- package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
- package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
- package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
- package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
- package/src/providers/whatsapp-messaging.ts +132 -0
- package/src/providers/whatsapp-omni.ts +586 -0
- package/src/providers/whatsapp-web-runtime.ts +3108 -0
- package/src/providers/whatsapp-web.ts +1037 -0
- package/src/providers/x-image-provenance.ts +523 -0
- package/src/providers/x-made-with-ai.ts +106 -0
- package/src/providers/x-transaction-id.ts +413 -0
- package/src/providers/x-web-runtime.ts +3485 -0
- package/src/providers/x-web.ts +2332 -0
- package/src/providers/x.ts +1889 -0
- package/src/providers/youtube-web-runtime.ts +1400 -0
- package/src/providers/youtube-web.ts +1221 -0
- package/src/read-admission-runtime.ts +91 -0
- package/src/read-client.ts +521 -0
- package/src/read-effect-platform.ts +7 -0
- package/src/read-effect-runtime.ts +14 -0
- package/src/read-effect.ts +52 -0
- package/src/read-projection-admission.ts +866 -0
- package/src/read-projections.ts +3151 -0
- package/src/recovery.ts +1492 -0
- package/src/reviewed-template.ts +146 -0
- package/src/run-journal.ts +1272 -0
- package/src/runtime.ts +5116 -0
- package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
- package/src/scripts/install-whatsapp-protocol.sh +232 -0
- package/src/scripts/recover-install-stages.ts +1491 -0
- package/src/scripts/resolve-state-home.ts +30 -0
- package/src/scripts/scaffold-web-provider.ts +973 -0
- package/src/scripts/sync-bundled-adapters.ts +695 -0
- package/src/session-secrets.ts +1183 -0
- package/src/state-helper.bunfig.toml +2 -0
- package/src/state-helper.ts +2914 -0
- package/src/storage.ts +3842 -0
- package/src/transport-policy.ts +2 -0
- package/src/usage.ts +182 -0
- package/src/version.ts +2 -0
- package/src/web-session-authentication-policy.ts +136 -0
- package/src/web-session-cleanup-admission.ts +2344 -0
- package/src/web-session-cleanup-join.ts +99 -0
- package/src/web-session-client.ts +996 -0
- package/src/web-session-contract-definitions.ts +820 -0
- package/src/web-session-contract-planning.ts +42 -0
- package/src/web-session-contracts.ts +228 -0
- package/src/web-session-cookies.ts +500 -0
- package/src/web-session-execution.ts +385 -0
- package/src/web-session-http.ts +373 -0
- package/src/web-session-read-errors.ts +19 -0
- package/src/web-session-read-runtime.ts +124 -0
- package/src/web-session-recovery.ts +818 -0
- package/src/web-session-sites.ts +58 -0
- package/src/web-session-template.ts +994 -0
- package/src/web-session.ts +93 -0
- package/src/whatsapp-client-binding.ts +21 -0
- package/src/whatsapp-client-types.ts +75 -0
- package/src/whatsapp-client.ts +395 -0
- package/src/whatsapp-export-coordinate.ts +17 -0
- package/src/whatsapp-message-bundle-v2.ts +195 -0
- package/src/whatsapp-message-like-me-cli.ts +195 -0
- package/src/whatsapp-message-like-me-export.ts +1072 -0
- package/src/whatsapp-message-like-me-source.ts +1012 -0
- package/tsconfig.json +67 -0
|
@@ -0,0 +1,2786 @@
|
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { types as nodeTypes } from "node:util";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TikTok consumer-web internal API policy and normalization.
|
|
6
|
+
*
|
|
7
|
+
* This module owns exact request shapes and bounded response projections. It
|
|
8
|
+
* deliberately has no network access and never accepts arbitrary endpoints,
|
|
9
|
+
* query parameters, headers, or proof material from callers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const TIKTOK_WEB_OPERATION_NAMES = Object.freeze([
|
|
13
|
+
"comments.create",
|
|
14
|
+
"comments.read",
|
|
15
|
+
"content.delete",
|
|
16
|
+
"content.save",
|
|
17
|
+
"content.schedule",
|
|
18
|
+
"content.share",
|
|
19
|
+
"feeds.read",
|
|
20
|
+
"likes.set",
|
|
21
|
+
"media.publish",
|
|
22
|
+
"media.read",
|
|
23
|
+
"messaging.list",
|
|
24
|
+
"messaging.read",
|
|
25
|
+
"messaging.send",
|
|
26
|
+
"posts.publish",
|
|
27
|
+
"profiles.read",
|
|
28
|
+
"posts.read",
|
|
29
|
+
"posts.repost",
|
|
30
|
+
"relationships.follow.set",
|
|
31
|
+
"replies.create",
|
|
32
|
+
] as const);
|
|
33
|
+
|
|
34
|
+
export type TikTokWebOperationName = (typeof TIKTOK_WEB_OPERATION_NAMES)[number];
|
|
35
|
+
export type TikTokWebRisk = "R1" | "R2" | "R3";
|
|
36
|
+
export type TikTokWebContractState = "observed" | "capture-required";
|
|
37
|
+
export type TikTokWebEvidence = "live-direct" | "live-har" | "first-party-bundle" | "none";
|
|
38
|
+
|
|
39
|
+
type TikTokWebReadRequestRule = {
|
|
40
|
+
readonly method: "GET";
|
|
41
|
+
readonly path: string;
|
|
42
|
+
readonly requiredQueryParameters: readonly string[];
|
|
43
|
+
readonly fixedQueryParameters: readonly (readonly [string, string])[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const MAX_TIKTOK_VIDEO_PUBLISH_BYTES = 128 * 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
export type TikTokWebOperationContract = {
|
|
49
|
+
readonly contractVersion?: 1 | 2;
|
|
50
|
+
readonly effect: "read" | "write";
|
|
51
|
+
readonly risk: TikTokWebRisk;
|
|
52
|
+
readonly state: TikTokWebContractState;
|
|
53
|
+
readonly evidence: TikTokWebEvidence;
|
|
54
|
+
readonly requests: readonly TikTokWebReadRequestRule[];
|
|
55
|
+
readonly reason: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const VIEWER_REQUEST = Object.freeze({
|
|
59
|
+
method: "GET" as const,
|
|
60
|
+
path: "/api/user/detail/self/",
|
|
61
|
+
requiredQueryParameters: Object.freeze([]),
|
|
62
|
+
fixedQueryParameters: Object.freeze([]),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const FOR_YOU_REQUEST = Object.freeze({
|
|
66
|
+
method: "GET" as const,
|
|
67
|
+
path: "/api/recommend/item_list/",
|
|
68
|
+
requiredQueryParameters: Object.freeze(["aid", "count"]),
|
|
69
|
+
fixedQueryParameters: Object.freeze([Object.freeze(["aid", "1988"] as const)]),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const COMMENTS_REQUEST = Object.freeze({
|
|
73
|
+
method: "GET" as const,
|
|
74
|
+
path: "/api/comment/list/",
|
|
75
|
+
requiredQueryParameters: Object.freeze(["aid", "aweme_id", "count", "cursor"]),
|
|
76
|
+
fixedQueryParameters: Object.freeze([Object.freeze(["aid", "1988"] as const)]),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const noRequests = (): readonly TikTokWebReadRequestRule[] => Object.freeze([]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Only the two signer-free reads proven against the current consumer site are
|
|
83
|
+
* executable. Endpoint-family knowledge is not dispatch authority.
|
|
84
|
+
*/
|
|
85
|
+
export const TIKTOK_WEB_OPERATIONS = Object.freeze({
|
|
86
|
+
"profiles.read": Object.freeze({
|
|
87
|
+
effect: "read",
|
|
88
|
+
risk: "R1",
|
|
89
|
+
state: "observed",
|
|
90
|
+
evidence: "live-har",
|
|
91
|
+
requests: Object.freeze([VIEWER_REQUEST]),
|
|
92
|
+
reason: "exact current-profile counts from the viewer-bound first-party user detail response",
|
|
93
|
+
}),
|
|
94
|
+
"feeds.read": Object.freeze({
|
|
95
|
+
effect: "read",
|
|
96
|
+
risk: "R1",
|
|
97
|
+
state: "observed",
|
|
98
|
+
evidence: "live-direct",
|
|
99
|
+
requests: Object.freeze([FOR_YOU_REQUEST]),
|
|
100
|
+
reason: "exact signer-free For You GET with current-account bootstrap",
|
|
101
|
+
}),
|
|
102
|
+
"posts.read": Object.freeze({
|
|
103
|
+
effect: "read",
|
|
104
|
+
risk: "R1",
|
|
105
|
+
state: "capture-required",
|
|
106
|
+
evidence: "live-direct",
|
|
107
|
+
requests: noRequests(),
|
|
108
|
+
reason: "item/detail and creator item-list reads returned no usable direct response without current proof material",
|
|
109
|
+
}),
|
|
110
|
+
"media.read": Object.freeze({
|
|
111
|
+
effect: "read",
|
|
112
|
+
risk: "R1",
|
|
113
|
+
state: "capture-required",
|
|
114
|
+
evidence: "none",
|
|
115
|
+
requests: noRequests(),
|
|
116
|
+
reason: "media detail and expiring playback URL handling require a separately reviewed response contract",
|
|
117
|
+
}),
|
|
118
|
+
"comments.read": Object.freeze({
|
|
119
|
+
effect: "read",
|
|
120
|
+
risk: "R1",
|
|
121
|
+
state: "observed",
|
|
122
|
+
evidence: "live-direct",
|
|
123
|
+
requests: Object.freeze([COMMENTS_REQUEST]),
|
|
124
|
+
reason: "exact signer-free comment-list GET with post binding and acknowledgement-free semantics",
|
|
125
|
+
}),
|
|
126
|
+
"messaging.list": Object.freeze({
|
|
127
|
+
effect: "read",
|
|
128
|
+
risk: "R1",
|
|
129
|
+
state: "capture-required",
|
|
130
|
+
evidence: "none",
|
|
131
|
+
requests: noRequests(),
|
|
132
|
+
reason: "inbox transport and acknowledgement behavior require a reviewed capture",
|
|
133
|
+
}),
|
|
134
|
+
"messaging.read": Object.freeze({
|
|
135
|
+
effect: "read",
|
|
136
|
+
risk: "R1",
|
|
137
|
+
state: "capture-required",
|
|
138
|
+
evidence: "none",
|
|
139
|
+
requests: noRequests(),
|
|
140
|
+
reason: "conversation transport, pagination, and acknowledgement behavior require a reviewed capture",
|
|
141
|
+
}),
|
|
142
|
+
"likes.set": Object.freeze({
|
|
143
|
+
effect: "write",
|
|
144
|
+
risk: "R2",
|
|
145
|
+
state: "capture-required",
|
|
146
|
+
evidence: "live-har",
|
|
147
|
+
requests: noRequests(),
|
|
148
|
+
reason: "item digg needs current csrf/proof material, exact mutation response, and independent readback",
|
|
149
|
+
}),
|
|
150
|
+
"content.save": Object.freeze({
|
|
151
|
+
effect: "write",
|
|
152
|
+
risk: "R2",
|
|
153
|
+
state: "capture-required",
|
|
154
|
+
evidence: "live-har",
|
|
155
|
+
requests: noRequests(),
|
|
156
|
+
reason: "item collect needs current csrf/proof material, exact mutation response, and independent saved-list readback",
|
|
157
|
+
}),
|
|
158
|
+
"content.delete": Object.freeze({
|
|
159
|
+
contractVersion: 1,
|
|
160
|
+
effect: "write",
|
|
161
|
+
risk: "R3",
|
|
162
|
+
state: "capture-required",
|
|
163
|
+
evidence: "live-har",
|
|
164
|
+
requests: noRequests(),
|
|
165
|
+
reason: "an authorized disposable lifecycle proves exact account-bound list/detail preflight, recyclable permission, and one accepted recycle response, but the post-list miss and canonical soft-200 shell are not a strict tombstone, and the mutation requires in-origin ACrawler/ZTI proof that the direct cookie transport cannot reproduce",
|
|
166
|
+
}),
|
|
167
|
+
"relationships.follow.set": Object.freeze({
|
|
168
|
+
effect: "write",
|
|
169
|
+
risk: "R2",
|
|
170
|
+
state: "capture-required",
|
|
171
|
+
evidence: "live-har",
|
|
172
|
+
requests: noRequests(),
|
|
173
|
+
reason: "follow AB variants need exact target, csrf/proof material, mutation response, and independent relationship readback",
|
|
174
|
+
}),
|
|
175
|
+
"comments.create": Object.freeze({
|
|
176
|
+
effect: "write",
|
|
177
|
+
risk: "R3",
|
|
178
|
+
state: "capture-required",
|
|
179
|
+
evidence: "live-har",
|
|
180
|
+
requests: noRequests(),
|
|
181
|
+
reason: "comment publish needs an authorized fixture and exact actor/root response binding",
|
|
182
|
+
}),
|
|
183
|
+
"replies.create": Object.freeze({
|
|
184
|
+
effect: "write",
|
|
185
|
+
risk: "R3",
|
|
186
|
+
state: "capture-required",
|
|
187
|
+
evidence: "live-har",
|
|
188
|
+
requests: noRequests(),
|
|
189
|
+
reason: "reply publish needs an authorized fixture and exact actor/root/parent response binding",
|
|
190
|
+
}),
|
|
191
|
+
"messaging.send": Object.freeze({
|
|
192
|
+
effect: "write",
|
|
193
|
+
risk: "R3",
|
|
194
|
+
state: "capture-required",
|
|
195
|
+
evidence: "none",
|
|
196
|
+
requests: noRequests(),
|
|
197
|
+
reason: "DM send and optional attachment transport require separate reviewed fixtures",
|
|
198
|
+
}),
|
|
199
|
+
"posts.publish": Object.freeze({
|
|
200
|
+
effect: "write",
|
|
201
|
+
risk: "R3",
|
|
202
|
+
state: "capture-required",
|
|
203
|
+
evidence: "none",
|
|
204
|
+
requests: noRequests(),
|
|
205
|
+
reason: "text-post publication needs an authorized fixture and audience/response binding",
|
|
206
|
+
}),
|
|
207
|
+
"media.publish": Object.freeze({
|
|
208
|
+
contractVersion: 2,
|
|
209
|
+
effect: "write",
|
|
210
|
+
risk: "R3",
|
|
211
|
+
state: "capture-required",
|
|
212
|
+
evidence: "first-party-bundle",
|
|
213
|
+
requests: noRequests(),
|
|
214
|
+
reason: "an authorized private disposable publish proves two Apply/Commit cycles, one project-post acceptance, exact caption/audience settings, and account-bound readback, but observed TOS traffic differs from the unexecuted multipart projections and project dispatch still requires reviewed in-origin ACrawler/ZTI proof generation",
|
|
215
|
+
}),
|
|
216
|
+
"content.schedule": Object.freeze({
|
|
217
|
+
effect: "write",
|
|
218
|
+
risk: "R3",
|
|
219
|
+
state: "capture-required",
|
|
220
|
+
evidence: "none",
|
|
221
|
+
requests: noRequests(),
|
|
222
|
+
reason: "creator scheduling and upload publication require exact authorized Studio captures",
|
|
223
|
+
}),
|
|
224
|
+
"content.share": Object.freeze({
|
|
225
|
+
effect: "write",
|
|
226
|
+
risk: "R3",
|
|
227
|
+
state: "capture-required",
|
|
228
|
+
evidence: "none",
|
|
229
|
+
requests: noRequests(),
|
|
230
|
+
reason: "share destinations have distinct externally visible effects and require reviewed fixtures",
|
|
231
|
+
}),
|
|
232
|
+
"posts.repost": Object.freeze({
|
|
233
|
+
effect: "write",
|
|
234
|
+
risk: "R3",
|
|
235
|
+
state: "capture-required",
|
|
236
|
+
evidence: "live-har",
|
|
237
|
+
requests: noRequests(),
|
|
238
|
+
reason: "upvote publish/delete requires exact current proof material, response binding, and independent readback",
|
|
239
|
+
}),
|
|
240
|
+
} as const satisfies Readonly<Record<TikTokWebOperationName, TikTokWebOperationContract>>);
|
|
241
|
+
|
|
242
|
+
export const TIKTOK_WEB_VIEWER_REQUEST = VIEWER_REQUEST;
|
|
243
|
+
|
|
244
|
+
export type TikTokWebR1OperationId =
|
|
245
|
+
| "viewer.current"
|
|
246
|
+
| "profiles.current"
|
|
247
|
+
| "feeds.for-you"
|
|
248
|
+
| "comments.list";
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Secret-free live evidence for the fixed direct-read boundary. No response
|
|
252
|
+
* values, cookies, proof tokens, or user content are retained here.
|
|
253
|
+
*/
|
|
254
|
+
export const tiktokWebDirectEvidenceSnapshot = Object.freeze({
|
|
255
|
+
schemaVersion: 1,
|
|
256
|
+
role: "revision-evidence-only" as const,
|
|
257
|
+
observedOn: "2026-07-23",
|
|
258
|
+
origin: "https://www.tiktok.com",
|
|
259
|
+
authentication: "browser-cookie-session" as const,
|
|
260
|
+
signerRequired: false,
|
|
261
|
+
operations: Object.freeze({
|
|
262
|
+
"viewer.current": Object.freeze({
|
|
263
|
+
method: "GET" as const,
|
|
264
|
+
path: VIEWER_REQUEST.path,
|
|
265
|
+
queryNames: Object.freeze([]),
|
|
266
|
+
responseBinding: Object.freeze(["userInfo.user.id", "userInfo.user.secUid"]),
|
|
267
|
+
}),
|
|
268
|
+
"profiles.current": Object.freeze({
|
|
269
|
+
method: "GET" as const,
|
|
270
|
+
path: VIEWER_REQUEST.path,
|
|
271
|
+
queryNames: Object.freeze([]),
|
|
272
|
+
responseBinding: Object.freeze([
|
|
273
|
+
"userInfo.user.id",
|
|
274
|
+
"userInfo.user.uniqueId",
|
|
275
|
+
"userInfo.stats.followerCount",
|
|
276
|
+
"userInfo.stats.followingCount",
|
|
277
|
+
"userInfo.stats.heartCount",
|
|
278
|
+
]),
|
|
279
|
+
}),
|
|
280
|
+
"feeds.for-you": Object.freeze({
|
|
281
|
+
method: "GET" as const,
|
|
282
|
+
path: FOR_YOU_REQUEST.path,
|
|
283
|
+
queryNames: Object.freeze(["aid", "count"]),
|
|
284
|
+
responseBinding: Object.freeze(["statusCode", "status_code", "itemList"]),
|
|
285
|
+
}),
|
|
286
|
+
"comments.list": Object.freeze({
|
|
287
|
+
method: "GET" as const,
|
|
288
|
+
path: COMMENTS_REQUEST.path,
|
|
289
|
+
queryNames: Object.freeze(["aid", "aweme_id", "count", "cursor"]),
|
|
290
|
+
responseBinding: Object.freeze(["status_code", "comments[].aweme_id", "cursor"]),
|
|
291
|
+
}),
|
|
292
|
+
}),
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const R1_REQUESTS = Object.freeze({
|
|
296
|
+
"viewer.current": VIEWER_REQUEST,
|
|
297
|
+
"profiles.current": VIEWER_REQUEST,
|
|
298
|
+
"feeds.for-you": FOR_YOU_REQUEST,
|
|
299
|
+
"comments.list": COMMENTS_REQUEST,
|
|
300
|
+
} as const satisfies Readonly<Record<TikTokWebR1OperationId, TikTokWebReadRequestRule>>);
|
|
301
|
+
|
|
302
|
+
type JsonRecord = Record<string, unknown>;
|
|
303
|
+
|
|
304
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
305
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function record(value: unknown, label: string): JsonRecord {
|
|
309
|
+
if (!isRecord(value)) throw new Error(`${label} must be an object`);
|
|
310
|
+
return value;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function requiredString(value: unknown, label: string, maximum: number): string {
|
|
314
|
+
if (
|
|
315
|
+
typeof value !== "string"
|
|
316
|
+
|| value.length < 1
|
|
317
|
+
|| value.length > maximum
|
|
318
|
+
|| /[\0\r]/u.test(value)
|
|
319
|
+
) throw new Error(`${label} must be a bounded string`);
|
|
320
|
+
return value;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function boundedText(value: unknown, label: string, maximum: number): string {
|
|
324
|
+
if (typeof value !== "string" || value.length > maximum || /[\0\r]/u.test(value)) {
|
|
325
|
+
throw new Error(`${label} must be bounded text`);
|
|
326
|
+
}
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function optionalString(value: unknown, label: string, maximum: number): string | null {
|
|
331
|
+
if (value === undefined || value === null) return null;
|
|
332
|
+
return requiredString(value, label, maximum);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function decimalId(value: unknown, label: string): string {
|
|
336
|
+
const id = requiredString(value, label, 32);
|
|
337
|
+
if (!/^[0-9]{1,32}$/u.test(id)) throw new Error(`${label} must be a decimal TikTok identifier`);
|
|
338
|
+
return id;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function secUid(value: unknown, label: string): string {
|
|
342
|
+
const id = requiredString(value, label, 256);
|
|
343
|
+
if (!/^[A-Za-z0-9._-]{16,256}$/u.test(id)) throw new Error(`${label} must be an exact TikTok secUid`);
|
|
344
|
+
return id;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function integer(
|
|
348
|
+
value: unknown,
|
|
349
|
+
label: string,
|
|
350
|
+
minimum = 0,
|
|
351
|
+
maximum = Number.MAX_SAFE_INTEGER,
|
|
352
|
+
): number {
|
|
353
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
|
|
354
|
+
throw new Error(`${label} must be an integer between ${minimum} and ${maximum}`);
|
|
355
|
+
}
|
|
356
|
+
return value as number;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function integerLike(
|
|
360
|
+
value: unknown,
|
|
361
|
+
label: string,
|
|
362
|
+
minimum = 0,
|
|
363
|
+
maximum = Number.MAX_SAFE_INTEGER,
|
|
364
|
+
): number {
|
|
365
|
+
if (typeof value === "number") return integer(value, label, minimum, maximum);
|
|
366
|
+
if (typeof value !== "string" || !/^(?:0|[1-9][0-9]{0,15})$/u.test(value)) {
|
|
367
|
+
throw new Error(`${label} must be a safe decimal integer`);
|
|
368
|
+
}
|
|
369
|
+
return integer(Number(value), label, minimum, maximum);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function boolean(value: unknown, label: string): boolean {
|
|
373
|
+
if (typeof value !== "boolean") throw new Error(`${label} must be boolean`);
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function zeroStatus(
|
|
378
|
+
root: JsonRecord,
|
|
379
|
+
fields: readonly string[],
|
|
380
|
+
label: string,
|
|
381
|
+
): void {
|
|
382
|
+
for (const field of fields) {
|
|
383
|
+
if (root[field] !== 0) throw new Error(`${label} did not return an exact success status`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function exactUrl(value: string | URL, label: string): URL {
|
|
388
|
+
let url: URL;
|
|
389
|
+
try {
|
|
390
|
+
url = value instanceof URL ? new URL(value.href) : new URL(value);
|
|
391
|
+
} catch {
|
|
392
|
+
throw new Error(`${label} must be an absolute URL`);
|
|
393
|
+
}
|
|
394
|
+
if (
|
|
395
|
+
url.origin !== "https://www.tiktok.com"
|
|
396
|
+
|| url.username !== ""
|
|
397
|
+
|| url.password !== ""
|
|
398
|
+
|| url.hash !== ""
|
|
399
|
+
) throw new Error(`${label} must use the exact https://www.tiktok.com origin`);
|
|
400
|
+
return url;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function exactSingleQuery(url: URL): ReadonlyMap<string, string> {
|
|
404
|
+
const result = new Map<string, string>();
|
|
405
|
+
for (const [name, value] of url.searchParams) {
|
|
406
|
+
if (result.has(name)) throw new Error(`TikTok request repeated query parameter ${name}`);
|
|
407
|
+
result.set(name, value);
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function decimalQuery(
|
|
413
|
+
values: ReadonlyMap<string, string>,
|
|
414
|
+
name: string,
|
|
415
|
+
minimum: number,
|
|
416
|
+
maximum: number,
|
|
417
|
+
): number {
|
|
418
|
+
const value = values.get(name);
|
|
419
|
+
if (value === undefined || !/^(?:0|[1-9][0-9]{0,15})$/u.test(value)) {
|
|
420
|
+
throw new Error(`TikTok request query parameter ${name} must be a decimal integer`);
|
|
421
|
+
}
|
|
422
|
+
return integer(Number(value), `TikTok request query parameter ${name}`, minimum, maximum);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export type TikTokWebR1RequestInput = {
|
|
426
|
+
readonly operation: TikTokWebR1OperationId;
|
|
427
|
+
readonly url: string | URL;
|
|
428
|
+
readonly method: string;
|
|
429
|
+
readonly body?: unknown;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
export type TikTokWebR1RequestBinding = {
|
|
433
|
+
readonly operation: TikTokWebR1OperationId;
|
|
434
|
+
readonly method: "GET";
|
|
435
|
+
readonly path: string;
|
|
436
|
+
readonly query: Readonly<Record<string, string>>;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
/** Prove one request is exactly one signer-free reviewed R1 exchange. */
|
|
440
|
+
export function authorizeTikTokWebR1Request(
|
|
441
|
+
input: TikTokWebR1RequestInput,
|
|
442
|
+
): TikTokWebR1RequestBinding {
|
|
443
|
+
const definition = R1_REQUESTS[input.operation];
|
|
444
|
+
if (definition === undefined) throw new Error("TikTok R1 operation is not allowlisted");
|
|
445
|
+
if (input.method.toUpperCase() !== "GET") throw new Error("TikTok R1 requests require GET");
|
|
446
|
+
if (input.body !== undefined) throw new Error("TikTok R1 requests may not contain a body");
|
|
447
|
+
const url = exactUrl(input.url, "TikTok R1 URL");
|
|
448
|
+
if (url.pathname !== definition.path) throw new Error("TikTok R1 request path is not reviewed");
|
|
449
|
+
const query = exactSingleQuery(url);
|
|
450
|
+
const allowed = new Set(definition.requiredQueryParameters);
|
|
451
|
+
const missing = definition.requiredQueryParameters.filter((name) => !query.has(name));
|
|
452
|
+
const extra = [...query.keys()].filter((name) => !allowed.has(name));
|
|
453
|
+
if (missing.length > 0) throw new Error(`TikTok R1 request omitted ${missing.join(", ")}`);
|
|
454
|
+
if (extra.length > 0) throw new Error(`TikTok R1 request contained unsupported parameter ${extra.join(", ")}`);
|
|
455
|
+
for (const [name, expected] of definition.fixedQueryParameters) {
|
|
456
|
+
if (query.get(name) !== expected) throw new Error(`TikTok R1 request changed fixed parameter ${name}`);
|
|
457
|
+
}
|
|
458
|
+
if (input.operation === "feeds.for-you") {
|
|
459
|
+
decimalQuery(query, "count", 1, 30);
|
|
460
|
+
} else if (input.operation === "comments.list") {
|
|
461
|
+
const postId = query.get("aweme_id");
|
|
462
|
+
if (postId === undefined) throw new Error("TikTok comments request omitted aweme_id");
|
|
463
|
+
decimalId(postId, "TikTok comments request aweme_id");
|
|
464
|
+
decimalQuery(query, "count", 1, 50);
|
|
465
|
+
decimalQuery(query, "cursor", 0, Number.MAX_SAFE_INTEGER);
|
|
466
|
+
}
|
|
467
|
+
return Object.freeze({
|
|
468
|
+
operation: input.operation,
|
|
469
|
+
method: "GET",
|
|
470
|
+
path: definition.path,
|
|
471
|
+
query: Object.freeze(Object.fromEntries([...query].sort(([left], [right]) => left.localeCompare(right)))),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export const tiktokWebHeaderSinkPolicy = Object.freeze({
|
|
476
|
+
browserManaged: Object.freeze([
|
|
477
|
+
"cookie",
|
|
478
|
+
"host",
|
|
479
|
+
"user-agent",
|
|
480
|
+
"content-length",
|
|
481
|
+
]),
|
|
482
|
+
browserManagedPrefixes: Object.freeze(["sec-", "proxy-"]),
|
|
483
|
+
fixedCodeHeaders: Object.freeze(["accept", "referer"]),
|
|
484
|
+
inOriginEphemeral: Object.freeze(["tt-csrf-token"]),
|
|
485
|
+
permittedRawSink: "network-request" as const,
|
|
486
|
+
persistentSinks: Object.freeze(["plan", "receipt", "log", "fixture"] as const),
|
|
487
|
+
forbiddenSources: Object.freeze(["manifest", "adapter", "user-input"] as const),
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
export type TikTokWebHeaderSource =
|
|
491
|
+
| "code"
|
|
492
|
+
| "in-origin-session"
|
|
493
|
+
| "manifest"
|
|
494
|
+
| "adapter"
|
|
495
|
+
| "user-input";
|
|
496
|
+
export type TikTokWebHeaderSink =
|
|
497
|
+
| "network-request"
|
|
498
|
+
| "plan"
|
|
499
|
+
| "receipt"
|
|
500
|
+
| "log"
|
|
501
|
+
| "fixture";
|
|
502
|
+
|
|
503
|
+
function hasAsciiControl(value: string): boolean {
|
|
504
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
505
|
+
const code = value.charCodeAt(index);
|
|
506
|
+
if (code <= 0x1f || code === 0x7f) return true;
|
|
507
|
+
}
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Raw cookie and csrf values can only enter the immediate network request.
|
|
513
|
+
* The current observed R1 runtime never requests or supplies a csrf value.
|
|
514
|
+
*/
|
|
515
|
+
export function enforceTikTokWebHeaderSinkPolicy(input: {
|
|
516
|
+
readonly source: TikTokWebHeaderSource;
|
|
517
|
+
readonly sink: TikTokWebHeaderSink;
|
|
518
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
519
|
+
}): Readonly<Record<string, string>> {
|
|
520
|
+
if (!isRecord(input.headers)) throw new Error("TikTok headers must be an object");
|
|
521
|
+
const entries = Object.entries(input.headers);
|
|
522
|
+
if (input.sink !== "network-request") {
|
|
523
|
+
if (entries.length > 0) throw new Error(`raw TikTok headers may not flow to ${input.sink}`);
|
|
524
|
+
return Object.freeze({});
|
|
525
|
+
}
|
|
526
|
+
if (tiktokWebHeaderSinkPolicy.forbiddenSources.includes(input.source as "manifest")) {
|
|
527
|
+
if (entries.length > 0) throw new Error(`${input.source} may not supply TikTok request headers`);
|
|
528
|
+
return Object.freeze({});
|
|
529
|
+
}
|
|
530
|
+
const normalized: Record<string, string> = {};
|
|
531
|
+
for (const [rawName, value] of entries) {
|
|
532
|
+
const name = rawName.toLowerCase();
|
|
533
|
+
if (!/^[a-z0-9-]+$/u.test(name) || Object.hasOwn(normalized, name)) {
|
|
534
|
+
throw new Error("TikTok request contained an invalid or duplicate header");
|
|
535
|
+
}
|
|
536
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 8_192 || hasAsciiControl(value)) {
|
|
537
|
+
throw new Error(`TikTok request header ${name} had an invalid value`);
|
|
538
|
+
}
|
|
539
|
+
if (
|
|
540
|
+
tiktokWebHeaderSinkPolicy.browserManaged.includes(name)
|
|
541
|
+
|| tiktokWebHeaderSinkPolicy.browserManagedPrefixes.some((prefix) => name.startsWith(prefix))
|
|
542
|
+
) throw new Error(`TikTok request header ${name} must be browser-managed`);
|
|
543
|
+
if (name === "tt-csrf-token") {
|
|
544
|
+
if (input.source !== "in-origin-session" || !/^[A-Za-z0-9._~-]{8,4096}$/u.test(value)) {
|
|
545
|
+
throw new Error("TikTok tt-csrf-token must come from the in-origin session");
|
|
546
|
+
}
|
|
547
|
+
} else if (name === "accept") {
|
|
548
|
+
if (input.source !== "code" || value !== "application/json, text/plain, */*") {
|
|
549
|
+
throw new Error("TikTok accept header must use the exact code-owned value");
|
|
550
|
+
}
|
|
551
|
+
} else if (name === "referer") {
|
|
552
|
+
if (
|
|
553
|
+
input.source !== "code"
|
|
554
|
+
|| (value !== "https://www.tiktok.com/" && value !== "https://www.tiktok.com/foryou")
|
|
555
|
+
) throw new Error("TikTok referer header must use a reviewed code-owned value");
|
|
556
|
+
} else {
|
|
557
|
+
throw new Error(`TikTok request header ${name} is not allowlisted`);
|
|
558
|
+
}
|
|
559
|
+
normalized[name] = value;
|
|
560
|
+
}
|
|
561
|
+
return Object.freeze(normalized);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export type TikTokWebViewer = {
|
|
565
|
+
readonly id: string;
|
|
566
|
+
readonly secUid: string;
|
|
567
|
+
readonly handle: string;
|
|
568
|
+
readonly displayName: string;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
export type TikTokWebProfile = TikTokWebViewer & {
|
|
572
|
+
readonly bio: string | null;
|
|
573
|
+
readonly websiteUrl: string | null;
|
|
574
|
+
readonly followers: number;
|
|
575
|
+
readonly following: number;
|
|
576
|
+
readonly likes: number;
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
export function parseTikTokWebViewerResponse(value: unknown): TikTokWebViewer {
|
|
580
|
+
const root = record(value, "TikTok current-account response");
|
|
581
|
+
zeroStatus(root, ["statusCode", "status_code"], "TikTok current-account response");
|
|
582
|
+
const userInfo = record(root.userInfo, "TikTok current-account response.userInfo");
|
|
583
|
+
const user = record(userInfo.user, "TikTok current-account response.user");
|
|
584
|
+
return Object.freeze({
|
|
585
|
+
id: decimalId(user.id, "TikTok current-account user.id"),
|
|
586
|
+
secUid: secUid(user.secUid, "TikTok current-account user.secUid"),
|
|
587
|
+
handle: requiredString(user.uniqueId, "TikTok current-account user.uniqueId", 64),
|
|
588
|
+
displayName: requiredString(user.nickname, "TikTok current-account user.nickname", 128),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function safePublicHttpUrl(value: unknown, label: string): string | null {
|
|
593
|
+
if (value === undefined || value === null || value === "") return null;
|
|
594
|
+
const text = requiredString(value, label, 2048);
|
|
595
|
+
let url: URL;
|
|
596
|
+
try {
|
|
597
|
+
url = new URL(text);
|
|
598
|
+
} catch {
|
|
599
|
+
throw new Error(`${label} must be an absolute HTTP URL`);
|
|
600
|
+
}
|
|
601
|
+
if (
|
|
602
|
+
(url.protocol !== "https:" && url.protocol !== "http:")
|
|
603
|
+
|| url.username !== ""
|
|
604
|
+
|| url.password !== ""
|
|
605
|
+
) throw new Error(`${label} must be a safe public HTTP URL`);
|
|
606
|
+
return url.href;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function matchingExactCount(
|
|
610
|
+
primary: JsonRecord,
|
|
611
|
+
secondary: JsonRecord | null,
|
|
612
|
+
key: "followerCount" | "followingCount" | "heartCount",
|
|
613
|
+
label: string,
|
|
614
|
+
): number {
|
|
615
|
+
const first = integerLike(primary[key], `${label}.${key}`);
|
|
616
|
+
if (secondary === null || secondary[key] === undefined) return first;
|
|
617
|
+
const second = integerLike(secondary[key], `${label}V2.${key}`);
|
|
618
|
+
if (first !== second) throw new Error(`TikTok profile response contained conflicting ${key} values`);
|
|
619
|
+
return first;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Project exact current-profile counts without retaining account-private IDs. */
|
|
623
|
+
export function parseTikTokWebProfileResponse(value: unknown): TikTokWebProfile {
|
|
624
|
+
const viewer = parseTikTokWebViewerResponse(value);
|
|
625
|
+
const root = record(value, "TikTok current-profile response");
|
|
626
|
+
const userInfo = record(root.userInfo, "TikTok current-profile response.userInfo");
|
|
627
|
+
const user = record(userInfo.user, "TikTok current-profile response.user");
|
|
628
|
+
const stats = record(userInfo.stats, "TikTok current-profile response.stats");
|
|
629
|
+
const statsV2 = userInfo.statsV2 === undefined
|
|
630
|
+
? null
|
|
631
|
+
: record(userInfo.statsV2, "TikTok current-profile response.statsV2");
|
|
632
|
+
const bioLink = isRecord(user.bioLink) ? user.bioLink : null;
|
|
633
|
+
return Object.freeze({
|
|
634
|
+
...viewer,
|
|
635
|
+
bio: user.signature === undefined
|
|
636
|
+
? null
|
|
637
|
+
: boundedText(user.signature, "TikTok current-profile response.user.signature", 4096),
|
|
638
|
+
websiteUrl: bioLink === null
|
|
639
|
+
? null
|
|
640
|
+
: safePublicHttpUrl(
|
|
641
|
+
bioLink.link ?? bioLink.url,
|
|
642
|
+
"TikTok current-profile response.user.bioLink",
|
|
643
|
+
),
|
|
644
|
+
followers: matchingExactCount(stats, statsV2, "followerCount", "TikTok current-profile response.stats"),
|
|
645
|
+
following: matchingExactCount(stats, statsV2, "followingCount", "TikTok current-profile response.stats"),
|
|
646
|
+
likes: matchingExactCount(stats, statsV2, "heartCount", "TikTok current-profile response.stats"),
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
type TikTokWebAuthor = {
|
|
651
|
+
readonly id: string;
|
|
652
|
+
readonly secUid: string;
|
|
653
|
+
readonly handle: string;
|
|
654
|
+
readonly displayName: string;
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
function normalizedAuthor(value: unknown, label: string, style: "item" | "comment"): TikTokWebAuthor {
|
|
658
|
+
const author = record(value, label);
|
|
659
|
+
return Object.freeze({
|
|
660
|
+
id: decimalId(style === "item" ? author.id : author.uid, `${label}.${style === "item" ? "id" : "uid"}`),
|
|
661
|
+
secUid: secUid(
|
|
662
|
+
style === "item" ? author.secUid : author.sec_uid,
|
|
663
|
+
`${label}.${style === "item" ? "secUid" : "sec_uid"}`,
|
|
664
|
+
),
|
|
665
|
+
handle: requiredString(
|
|
666
|
+
style === "item" ? author.uniqueId : author.unique_id,
|
|
667
|
+
`${label}.${style === "item" ? "uniqueId" : "unique_id"}`,
|
|
668
|
+
64,
|
|
669
|
+
),
|
|
670
|
+
displayName: requiredString(author.nickname, `${label}.nickname`, 128),
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function normalizedItem(value: unknown, index: number): Readonly<Record<string, unknown>> {
|
|
675
|
+
const label = `TikTok feed item ${index + 1}`;
|
|
676
|
+
const item = record(value, label);
|
|
677
|
+
const id = decimalId(item.id, `${label}.id`);
|
|
678
|
+
const author = normalizedAuthor(item.author, `${label}.author`, "item");
|
|
679
|
+
const stats = record(item.stats, `${label}.stats`);
|
|
680
|
+
const video = record(item.video, `${label}.video`);
|
|
681
|
+
const videoId = optionalString(video.id, `${label}.video.id`, 128);
|
|
682
|
+
const ratio = optionalString(video.ratio, `${label}.video.ratio`, 32);
|
|
683
|
+
return Object.freeze({
|
|
684
|
+
id,
|
|
685
|
+
description: boundedText(item.desc, `${label}.desc`, 8_192),
|
|
686
|
+
createdAtUnix: integer(item.createTime, `${label}.createTime`, 0),
|
|
687
|
+
author,
|
|
688
|
+
viewerState: Object.freeze({
|
|
689
|
+
liked: boolean(item.digged, `${label}.digged`),
|
|
690
|
+
saved: boolean(item.collected, `${label}.collected`),
|
|
691
|
+
}),
|
|
692
|
+
metrics: Object.freeze({
|
|
693
|
+
likes: integerLike(stats.diggCount, `${label}.stats.diggCount`),
|
|
694
|
+
comments: integerLike(stats.commentCount, `${label}.stats.commentCount`),
|
|
695
|
+
shares: integerLike(stats.shareCount, `${label}.stats.shareCount`),
|
|
696
|
+
plays: integerLike(stats.playCount, `${label}.stats.playCount`),
|
|
697
|
+
saves: stats.collectCount === undefined
|
|
698
|
+
? null
|
|
699
|
+
: integerLike(stats.collectCount, `${label}.stats.collectCount`),
|
|
700
|
+
}),
|
|
701
|
+
media: Object.freeze({
|
|
702
|
+
type: "video",
|
|
703
|
+
id: videoId,
|
|
704
|
+
durationSeconds: integer(video.duration, `${label}.video.duration`, 0, 24 * 60 * 60),
|
|
705
|
+
width: integer(video.width, `${label}.video.width`, 1, 65_535),
|
|
706
|
+
height: integer(video.height, `${label}.video.height`, 1, 65_535),
|
|
707
|
+
ratio,
|
|
708
|
+
}),
|
|
709
|
+
url: `https://www.tiktok.com/@${encodeURIComponent(author.handle)}/video/${id}`,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function normalizeTikTokWebFeedResponse(
|
|
714
|
+
value: unknown,
|
|
715
|
+
requestedLimit: number,
|
|
716
|
+
): Readonly<Record<string, unknown>> {
|
|
717
|
+
integer(requestedLimit, "TikTok requested feed limit", 1, 30);
|
|
718
|
+
const root = record(value, "TikTok For You response");
|
|
719
|
+
zeroStatus(root, ["statusCode", "status_code"], "TikTok For You response");
|
|
720
|
+
if (!Array.isArray(root.itemList)) throw new Error("TikTok For You response.itemList must be an array");
|
|
721
|
+
if (root.itemList.length > requestedLimit) {
|
|
722
|
+
throw new Error("TikTok For You response exceeded the requested complete-page limit");
|
|
723
|
+
}
|
|
724
|
+
return Object.freeze({
|
|
725
|
+
posts: Object.freeze(root.itemList.map(normalizedItem)),
|
|
726
|
+
hasMore: boolean(root.hasMore, "TikTok For You response.hasMore"),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function normalizedComment(
|
|
731
|
+
value: unknown,
|
|
732
|
+
index: number,
|
|
733
|
+
requestedPostId: string,
|
|
734
|
+
): Readonly<Record<string, unknown>> {
|
|
735
|
+
const label = `TikTok comment ${index + 1}`;
|
|
736
|
+
const comment = record(value, label);
|
|
737
|
+
const returnedPostId = decimalId(comment.aweme_id, `${label}.aweme_id`);
|
|
738
|
+
if (returnedPostId !== requestedPostId) {
|
|
739
|
+
throw new Error("TikTok comments response did not bind the requested post");
|
|
740
|
+
}
|
|
741
|
+
const userDigged = integer(comment.user_digged, `${label}.user_digged`, 0, 1);
|
|
742
|
+
return Object.freeze({
|
|
743
|
+
id: decimalId(comment.cid, `${label}.cid`),
|
|
744
|
+
postId: returnedPostId,
|
|
745
|
+
text: boundedText(comment.text, `${label}.text`, 8_192),
|
|
746
|
+
createdAtUnix: integer(comment.create_time, `${label}.create_time`, 0),
|
|
747
|
+
author: normalizedAuthor(comment.user, `${label}.user`, "comment"),
|
|
748
|
+
parentCommentId: optionalString(comment.reply_id, `${label}.reply_id`, 32),
|
|
749
|
+
repliedToCommentId: optionalString(comment.reply_to_reply_id, `${label}.reply_to_reply_id`, 32),
|
|
750
|
+
replyCount: integer(comment.reply_comment_total, `${label}.reply_comment_total`, 0),
|
|
751
|
+
likeCount: integer(comment.digg_count, `${label}.digg_count`, 0),
|
|
752
|
+
viewerLiked: userDigged === 1,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export function normalizeTikTokWebCommentsResponse(
|
|
757
|
+
value: unknown,
|
|
758
|
+
requestedPostIdValue: unknown,
|
|
759
|
+
requestedLimit: number,
|
|
760
|
+
): Readonly<Record<string, unknown>> {
|
|
761
|
+
const requestedPostId = decimalId(requestedPostIdValue, "requested TikTok post ID");
|
|
762
|
+
integer(requestedLimit, "TikTok requested comment limit", 1, 50);
|
|
763
|
+
const root = record(value, "TikTok comments response");
|
|
764
|
+
zeroStatus(root, ["status_code"], "TikTok comments response");
|
|
765
|
+
if (!Array.isArray(root.comments)) throw new Error("TikTok comments response.comments must be an array");
|
|
766
|
+
if (root.comments.length > requestedLimit) {
|
|
767
|
+
throw new Error("TikTok comments response exceeded the requested complete-page limit");
|
|
768
|
+
}
|
|
769
|
+
return Object.freeze({
|
|
770
|
+
comments: Object.freeze(
|
|
771
|
+
root.comments.map((comment, index) => normalizedComment(comment, index, requestedPostId)),
|
|
772
|
+
),
|
|
773
|
+
cursor: integer(root.cursor, "TikTok comments response.cursor", 0),
|
|
774
|
+
hasMore: integer(root.has_more, "TikTok comments response.has_more", 0, 1) === 1,
|
|
775
|
+
total: integer(root.total, "TikTok comments response.total", 0),
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Secret-free structural evidence retained from TikTok Studio's public,
|
|
781
|
+
* first-party bundles. This snapshot is not dispatch authority: browser-common
|
|
782
|
+
* request envelopes, authenticated response envelopes, and readback shapes
|
|
783
|
+
* remain capture-required.
|
|
784
|
+
*/
|
|
785
|
+
export const tiktokWebStudioBundleEvidenceSnapshot = Object.freeze({
|
|
786
|
+
schemaVersion: 1,
|
|
787
|
+
role: "bundle-evidence-only" as const,
|
|
788
|
+
observedOn: "2026-08-22",
|
|
789
|
+
origin: "https://www.tiktok.com",
|
|
790
|
+
authentication: "browser-cookie-session" as const,
|
|
791
|
+
upload: Object.freeze({
|
|
792
|
+
auth: "GET /api/v1/video/upload/auth/",
|
|
793
|
+
apply: "GET /top/v1 Action=ApplyUploadInner Version=2020-11-19",
|
|
794
|
+
transfer: Object.freeze([
|
|
795
|
+
"POST /upload/v1/{oid} phase=init",
|
|
796
|
+
"POST /upload/v1/{oid} phase=transfer",
|
|
797
|
+
"POST /upload/v1/{oid} phase=finish",
|
|
798
|
+
]),
|
|
799
|
+
commit: "POST /top/v1 Action=CommitUploadInner Version=2020-11-19",
|
|
800
|
+
partBytes: 3_145_728,
|
|
801
|
+
}),
|
|
802
|
+
publish: Object.freeze({
|
|
803
|
+
create: "POST /tiktok/web/project/post/v1/",
|
|
804
|
+
status: "GET /tiktok/web/project/status/v1/",
|
|
805
|
+
postType: 3,
|
|
806
|
+
}),
|
|
807
|
+
deletion: Object.freeze({
|
|
808
|
+
detail: "GET /api/v1/post/detail/ item_id={post-id}",
|
|
809
|
+
mutate: "POST /tiktok/post/edit/v1/ scene=1",
|
|
810
|
+
deleteTypes: Object.freeze({ normal: 0, trashBin: 1 }),
|
|
811
|
+
}),
|
|
812
|
+
unresolvedForDispatch: Object.freeze([
|
|
813
|
+
"provider-selected upload node, TOS origin, and authenticated response binding",
|
|
814
|
+
"mutating Studio common-query request envelopes",
|
|
815
|
+
"exact authenticated upload, transfer, commit, publish, status, and recycle response envelopes",
|
|
816
|
+
"fresh viewer and authored-target binding",
|
|
817
|
+
"processing completion and independent exact-post presence or absence readback",
|
|
818
|
+
]),
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The supplied successful live capture retained only structural facts. It is
|
|
823
|
+
* useful for narrowing future egress, but not for choosing a request, parsing
|
|
824
|
+
* a publish response, or promoting media.publish. In particular, every route,
|
|
825
|
+
* query value, body field, account value, and accepted target was redacted.
|
|
826
|
+
*/
|
|
827
|
+
export const tiktokWebSanitizedPublishCaptureEvidenceSnapshot = Object.freeze({
|
|
828
|
+
schemaVersion: 1,
|
|
829
|
+
role: "structural-live-evidence-only" as const,
|
|
830
|
+
observedOn: "2026-08-23",
|
|
831
|
+
targetOrigin: "https://www.tiktok.com",
|
|
832
|
+
observedEntries: 1_344,
|
|
833
|
+
writeCandidateCount: 5,
|
|
834
|
+
writeCandidateSamples: 8,
|
|
835
|
+
remoteOrigins: Object.freeze([
|
|
836
|
+
"https://lf16-tiktok-web.tiktokcdn-us.com",
|
|
837
|
+
"https://lf16-cdn-tos.tiktokcdn-us.com",
|
|
838
|
+
"https://tos16-up-useast8.tiktokcdn-us.com",
|
|
839
|
+
"https://tos19-up-useast8.tiktokcdn-us.com",
|
|
840
|
+
]),
|
|
841
|
+
observedUploadOrigins: Object.freeze([
|
|
842
|
+
"https://tos16-up-useast8.tiktokcdn-us.com",
|
|
843
|
+
"https://tos19-up-useast8.tiktokcdn-us.com",
|
|
844
|
+
]),
|
|
845
|
+
unresolvedForDispatch: Object.freeze([
|
|
846
|
+
"semantic operation ownership for every retained write candidate",
|
|
847
|
+
"exact route, query names and values, request fields, and credential sinks",
|
|
848
|
+
"response-selected upload credential, header, and accepted-status binding",
|
|
849
|
+
"current account, project, post, and audience response binding",
|
|
850
|
+
"processing completion and independent exact-post readback",
|
|
851
|
+
]),
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Secret-free structural facts retained from one authorized private video
|
|
856
|
+
* publish/readback/recycle-bin lifecycle. The disposable post ID, actor,
|
|
857
|
+
* caption, project ID, cookies, proof material, request values, and signed
|
|
858
|
+
* media URLs are deliberately absent. This closes response/readback semantics
|
|
859
|
+
* but not executable transport authority: Studio generated ACrawler/ZTI proof
|
|
860
|
+
* in-origin, and the observed TOS exchanges did not match the unexecuted
|
|
861
|
+
* phase=init|transfer|finish projections below.
|
|
862
|
+
*/
|
|
863
|
+
export const tiktokWebDisposableVideoLifecycleEvidenceSnapshot = Object.freeze({
|
|
864
|
+
schemaVersion: 1,
|
|
865
|
+
role: "live-authorized-disposable-cycle" as const,
|
|
866
|
+
observedOn: "2026-08-24",
|
|
867
|
+
origin: "https://www.tiktok.com",
|
|
868
|
+
media: Object.freeze({
|
|
869
|
+
mediaType: "video/mp4" as const,
|
|
870
|
+
binding: "exact-plan-bound-private-fixture" as const,
|
|
871
|
+
audience: "private" as const,
|
|
872
|
+
}),
|
|
873
|
+
publish: Object.freeze({
|
|
874
|
+
uploadAuth: "GET /api/v1/video/upload/auth/ HTTP 200",
|
|
875
|
+
applyCommitCycles: 2,
|
|
876
|
+
projectCreate: "POST /tiktok/web/project/post/v1/ HTTP 200",
|
|
877
|
+
projectResponseBinding: Object.freeze([
|
|
878
|
+
"status_code=0",
|
|
879
|
+
"project_id",
|
|
880
|
+
"single_post_resp_list[0].item_id",
|
|
881
|
+
"single_post_resp_list[0].status_code=0",
|
|
882
|
+
] as const),
|
|
883
|
+
readback: Object.freeze([
|
|
884
|
+
"bound current account",
|
|
885
|
+
"exact item ID",
|
|
886
|
+
"exact caption",
|
|
887
|
+
"private visibility",
|
|
888
|
+
] as const),
|
|
889
|
+
}),
|
|
890
|
+
recycle: Object.freeze({
|
|
891
|
+
preflight: Object.freeze([
|
|
892
|
+
"account-bound exact-caption content-list presence",
|
|
893
|
+
"GET /api/v1/post/detail/ HTTP 200 with exact item binding",
|
|
894
|
+
"exact caption binding",
|
|
895
|
+
"is_recyclable=true permission",
|
|
896
|
+
] as const),
|
|
897
|
+
mutation: Object.freeze({
|
|
898
|
+
request: "POST /tiktok/post/edit/v1/ HTTP 200",
|
|
899
|
+
scene: 1,
|
|
900
|
+
deleteType: 1,
|
|
901
|
+
acceptedResponseBinding: Object.freeze([
|
|
902
|
+
"status_code=0",
|
|
903
|
+
"exact item ID",
|
|
904
|
+
] as const),
|
|
905
|
+
}),
|
|
906
|
+
readback: Object.freeze({
|
|
907
|
+
absenceProven: false,
|
|
908
|
+
contentListMissProvesAbsence: false,
|
|
909
|
+
independentCanonicalObservation:
|
|
910
|
+
"exact canonical post URL became a soft-200 shell without the target video, caption, or media",
|
|
911
|
+
providerRecycleFolder: "app-only" as const,
|
|
912
|
+
}),
|
|
913
|
+
}),
|
|
914
|
+
executableAudit: Object.freeze({
|
|
915
|
+
containedBrowser: Object.freeze({
|
|
916
|
+
availablePrimitive:
|
|
917
|
+
"code-owned same-origin evaluation with bounded post-request network observation",
|
|
918
|
+
state: "insufficient" as const,
|
|
919
|
+
blockers: Object.freeze([
|
|
920
|
+
"no reviewed Studio interceptor revision is bound before dispatch",
|
|
921
|
+
"network observation can attest emitted request metadata only after the write may have started",
|
|
922
|
+
"a generic evaluated fetch does not prove ACrawler/ZTI attached the captured request shape",
|
|
923
|
+
] as const),
|
|
924
|
+
}),
|
|
925
|
+
mediaPublish: Object.freeze({
|
|
926
|
+
state: "capture-required" as const,
|
|
927
|
+
blockers: Object.freeze([
|
|
928
|
+
"live TOS requests do not match the unexecuted phase-based multipart projections",
|
|
929
|
+
"project publish requires in-origin ACrawler interception and AB-gated ZTI proof",
|
|
930
|
+
"no reviewed proof-only transport can produce and bind those ephemeral values",
|
|
931
|
+
] as const),
|
|
932
|
+
}),
|
|
933
|
+
contentDelete: Object.freeze({
|
|
934
|
+
state: "capture-required" as const,
|
|
935
|
+
blockers: Object.freeze([
|
|
936
|
+
"recycle dispatch requires in-origin ACrawler interception and AB-gated ZTI proof",
|
|
937
|
+
"the cookie-only web-session client cannot generate or attest that proof",
|
|
938
|
+
"post-list nonappearance and an unmarked canonical soft-200 shell are not strict tombstone evidence",
|
|
939
|
+
] as const),
|
|
940
|
+
}),
|
|
941
|
+
}),
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
const TIKTOK_SANITIZED_CAPTURE_UPLOAD_ORIGINS = new Set(
|
|
945
|
+
tiktokWebSanitizedPublishCaptureEvidenceSnapshot.observedUploadOrigins,
|
|
946
|
+
);
|
|
947
|
+
|
|
948
|
+
function exactObjectKeys(
|
|
949
|
+
value: JsonRecord,
|
|
950
|
+
required: readonly string[],
|
|
951
|
+
optional: readonly string[],
|
|
952
|
+
label: string,
|
|
953
|
+
): void {
|
|
954
|
+
const allowed = new Set([...required, ...optional]);
|
|
955
|
+
if (
|
|
956
|
+
required.some((key) => !Object.hasOwn(value, key))
|
|
957
|
+
|| Object.keys(value).some((key) => !allowed.has(key))
|
|
958
|
+
) throw new Error(`${label} changed its bundle-proven fields`);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function opaqueBundleString(value: unknown, label: string, maximum: number): string {
|
|
962
|
+
const text = requiredString(value, label, maximum);
|
|
963
|
+
if (/\n/u.test(text)) throw new Error(`${label} must not contain line breaks`);
|
|
964
|
+
return text;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function exactHttpsUploadHost(value: unknown, label: string): string {
|
|
968
|
+
const text = opaqueBundleString(value, label, 2_048);
|
|
969
|
+
let url: URL;
|
|
970
|
+
try {
|
|
971
|
+
url = new URL(text);
|
|
972
|
+
} catch {
|
|
973
|
+
throw new Error(`${label} must be an absolute HTTPS origin`);
|
|
974
|
+
}
|
|
975
|
+
if (
|
|
976
|
+
url.protocol !== "https:"
|
|
977
|
+
|| url.username !== ""
|
|
978
|
+
|| url.password !== ""
|
|
979
|
+
|| url.pathname !== "/"
|
|
980
|
+
|| url.search !== ""
|
|
981
|
+
|| url.hash !== ""
|
|
982
|
+
) throw new Error(`${label} must be an exact HTTPS origin`);
|
|
983
|
+
if (!TIKTOK_SANITIZED_CAPTURE_UPLOAD_ORIGINS.has(url.origin)) {
|
|
984
|
+
throw new Error(`${label} was not one exact origin retained by the sanitized live capture`);
|
|
985
|
+
}
|
|
986
|
+
return url.origin;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function bundleId(value: unknown, label: string, maximum = 2_048): string {
|
|
990
|
+
const id = opaqueBundleString(value, label, maximum);
|
|
991
|
+
if (!/^[A-Za-z0-9._~:@+/=-]+$/u.test(id)) {
|
|
992
|
+
throw new Error(`${label} must be a bounded provider identifier`);
|
|
993
|
+
}
|
|
994
|
+
return id;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
export type TikTokBundleRequestProjection = Readonly<{
|
|
998
|
+
body?: Readonly<Record<string, unknown>>;
|
|
999
|
+
method: "GET" | "POST";
|
|
1000
|
+
path: string;
|
|
1001
|
+
query: Readonly<Record<string, string>>;
|
|
1002
|
+
}>;
|
|
1003
|
+
|
|
1004
|
+
export type TikTokStudioRuntimeSecurityProjection = Readonly<{
|
|
1005
|
+
acrawler: "required" | "not-listed-for-route";
|
|
1006
|
+
antiCsrf: "required" | "not-listed-for-route";
|
|
1007
|
+
credentials: "include";
|
|
1008
|
+
csrfHeader: "in-origin-ephemeral" | "not-explicit-for-route";
|
|
1009
|
+
execution: "authenticated-in-origin-studio-session";
|
|
1010
|
+
verifyFp: "not-requested-by-base-query";
|
|
1011
|
+
zti: "ab-gated" | "not-listed-for-route";
|
|
1012
|
+
}>;
|
|
1013
|
+
|
|
1014
|
+
export type TikTokStudioRequestProjection = TikTokBundleRequestProjection & Readonly<{
|
|
1015
|
+
runtimeSecurity: TikTokStudioRuntimeSecurityProjection;
|
|
1016
|
+
}>;
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Secret-free request-security facts from the current public Studio bundles.
|
|
1020
|
+
* The named SDKs run inside the authenticated Studio origin. This snapshot
|
|
1021
|
+
* neither contains nor authorizes synthesizing a CSRF token, fingerprint, or
|
|
1022
|
+
* proof header outside that origin.
|
|
1023
|
+
*/
|
|
1024
|
+
export const tiktokWebStudioSecurityEvidenceSnapshot = Object.freeze({
|
|
1025
|
+
schemaVersion: 1,
|
|
1026
|
+
role: "bundle-evidence-only" as const,
|
|
1027
|
+
observedOn: "2026-08-24",
|
|
1028
|
+
aid: 1988,
|
|
1029
|
+
baseQuery: Object.freeze({
|
|
1030
|
+
aid: "1988",
|
|
1031
|
+
ttp2TargetIdc: "useast8",
|
|
1032
|
+
verifyFp: "first-profile-read-only-request-only" as const,
|
|
1033
|
+
}),
|
|
1034
|
+
acrawler: Object.freeze({
|
|
1035
|
+
intercept: true,
|
|
1036
|
+
mode: 513,
|
|
1037
|
+
paths: Object.freeze([
|
|
1038
|
+
"/api/v1/web/project/post",
|
|
1039
|
+
"/api/v1/item/create/bulk/",
|
|
1040
|
+
"/api/v1/item/create/",
|
|
1041
|
+
"/api/upload/search/user/",
|
|
1042
|
+
"/api/upload/challenge/sug/",
|
|
1043
|
+
"/api/post/item_list/",
|
|
1044
|
+
"/api/v1/user/profile/upload/",
|
|
1045
|
+
"/api/v1/video/upload/auth/",
|
|
1046
|
+
"/api/v1/draft/create_update/",
|
|
1047
|
+
"/tiktok/web/project/post/v1/",
|
|
1048
|
+
"/tiktok/web/project/cancel/v1/",
|
|
1049
|
+
"/tiktok/post/edit/v1/",
|
|
1050
|
+
"/api/user/list/",
|
|
1051
|
+
]),
|
|
1052
|
+
}),
|
|
1053
|
+
antiCsrf: Object.freeze({
|
|
1054
|
+
host: "www.tiktok.com",
|
|
1055
|
+
method: "POST" as const,
|
|
1056
|
+
paths: Object.freeze([
|
|
1057
|
+
"/api/v1/post_schedule/ack/",
|
|
1058
|
+
"/api/v1/video/transcode/enable/",
|
|
1059
|
+
]),
|
|
1060
|
+
}),
|
|
1061
|
+
zti: Object.freeze({
|
|
1062
|
+
abGate: "creation_use_zti",
|
|
1063
|
+
certType: "header",
|
|
1064
|
+
scene: "tt_fetch",
|
|
1065
|
+
signVersion: 2,
|
|
1066
|
+
paths: Object.freeze([
|
|
1067
|
+
"/api/v1/web/project/post/",
|
|
1068
|
+
"/api/v1/item/create/bulk/",
|
|
1069
|
+
"/tiktok/web/project/post/v1/",
|
|
1070
|
+
"/tiktok/post/edit/v1/",
|
|
1071
|
+
]),
|
|
1072
|
+
}),
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Secret-free facts retained from the authenticated, read-only Studio upload
|
|
1077
|
+
* page. No credential value, cookie, account content, or raw response is
|
|
1078
|
+
* retained by this snapshot.
|
|
1079
|
+
*/
|
|
1080
|
+
export const tiktokWebVideoUploadAuthLiveEvidenceSnapshot = Object.freeze({
|
|
1081
|
+
schemaVersion: 1,
|
|
1082
|
+
role: "live-read-only-evidence" as const,
|
|
1083
|
+
observedOn: "2026-08-24",
|
|
1084
|
+
origin: "https://www.tiktok.com",
|
|
1085
|
+
request: "GET /api/v1/video/upload/auth/?aid=1988",
|
|
1086
|
+
status: 200,
|
|
1087
|
+
contentType: "application/json; charset=utf-8",
|
|
1088
|
+
storeRegion: "US",
|
|
1089
|
+
videoSpaceName: "tiktok",
|
|
1090
|
+
clockFormat: "YYYY-MM-DDTHH:mm:ssZ",
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
export const TIKTOK_VIDEO_UPLOADER_REGIONS = Object.freeze({
|
|
1094
|
+
ttp: Object.freeze({
|
|
1095
|
+
publicRegion: "ttp" as const,
|
|
1096
|
+
signingRegion: "US-TTP" as const,
|
|
1097
|
+
targetIdc: null,
|
|
1098
|
+
useServerCurrentTime: true as const,
|
|
1099
|
+
videoUrl: "https://www.tiktok.com/top/v1" as const,
|
|
1100
|
+
}),
|
|
1101
|
+
ttp2: Object.freeze({
|
|
1102
|
+
publicRegion: "ttp2" as const,
|
|
1103
|
+
signingRegion: "US-TTP" as const,
|
|
1104
|
+
targetIdc: "useast8" as const,
|
|
1105
|
+
useServerCurrentTime: true as const,
|
|
1106
|
+
videoUrl: "https://www.tiktok.com/top/v1" as const,
|
|
1107
|
+
}),
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
export type TikTokVideoUploaderPublicRegion = keyof typeof TIKTOK_VIDEO_UPLOADER_REGIONS;
|
|
1111
|
+
|
|
1112
|
+
export function resolveTikTokVideoUploaderRegion(
|
|
1113
|
+
value: unknown,
|
|
1114
|
+
): (typeof TIKTOK_VIDEO_UPLOADER_REGIONS)[TikTokVideoUploaderPublicRegion] {
|
|
1115
|
+
if (value !== "ttp" && value !== "ttp2") {
|
|
1116
|
+
throw new Error("TikTok uploader public region is not bundle-proven");
|
|
1117
|
+
}
|
|
1118
|
+
return TIKTOK_VIDEO_UPLOADER_REGIONS[value];
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** Build the exact upload-auth read observed from the current Studio page. */
|
|
1122
|
+
export function buildTikTokVideoUploadAuthRequest(): TikTokBundleRequestProjection {
|
|
1123
|
+
return Object.freeze({
|
|
1124
|
+
method: "GET",
|
|
1125
|
+
path: "/api/v1/video/upload/auth/",
|
|
1126
|
+
query: Object.freeze({ aid: "1988" }),
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
export type TikTokVideoUploadTokenProjection = Readonly<{
|
|
1131
|
+
accessKeyId: string;
|
|
1132
|
+
clockState: "reviewed-utc-second";
|
|
1133
|
+
expiresAtIso: string;
|
|
1134
|
+
secretAccessKey: string;
|
|
1135
|
+
serverCurrentTimeIso: string;
|
|
1136
|
+
sessionToken: string;
|
|
1137
|
+
}>;
|
|
1138
|
+
|
|
1139
|
+
function tikTokAuthUtcSecond(value: unknown, label: string): string {
|
|
1140
|
+
if (
|
|
1141
|
+
typeof value !== "string"
|
|
1142
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.test(value)
|
|
1143
|
+
) throw new Error(`${label} must use the reviewed UTC-second wire format`);
|
|
1144
|
+
const milliseconds = Date.parse(value);
|
|
1145
|
+
if (
|
|
1146
|
+
!Number.isFinite(milliseconds)
|
|
1147
|
+
|| new Date(milliseconds).toISOString() !== `${value.slice(0, -1)}.000Z`
|
|
1148
|
+
) throw new Error(`${label} is not a canonical UTC instant`);
|
|
1149
|
+
return new Date(milliseconds).toISOString();
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
type ParsedTikTokUploadAuthToken = Readonly<{
|
|
1153
|
+
accessKeyId: string;
|
|
1154
|
+
currentTimeIso: string;
|
|
1155
|
+
expiresAtIso: string;
|
|
1156
|
+
secretAccessKey: string;
|
|
1157
|
+
sessionToken: string;
|
|
1158
|
+
}>;
|
|
1159
|
+
|
|
1160
|
+
function parseTikTokUploadAuthToken(
|
|
1161
|
+
value: unknown,
|
|
1162
|
+
label: string,
|
|
1163
|
+
expectedSpaceName: "tt_audio_mode" | "tiktok-ai-frame" | "tiktok",
|
|
1164
|
+
): ParsedTikTokUploadAuthToken {
|
|
1165
|
+
const token = record(value, label);
|
|
1166
|
+
exactObjectKeys(token, [
|
|
1167
|
+
"access_key_id",
|
|
1168
|
+
"current_time",
|
|
1169
|
+
"expired_time",
|
|
1170
|
+
"secret_acess_key",
|
|
1171
|
+
"session_token",
|
|
1172
|
+
"space_name",
|
|
1173
|
+
], [], label);
|
|
1174
|
+
if (token.space_name !== expectedSpaceName) {
|
|
1175
|
+
throw new Error(`${label}.space_name changed its reviewed value`);
|
|
1176
|
+
}
|
|
1177
|
+
const currentTimeIso = tikTokAuthUtcSecond(token.current_time, `${label}.current_time`);
|
|
1178
|
+
const expiresAtIso = tikTokAuthUtcSecond(token.expired_time, `${label}.expired_time`);
|
|
1179
|
+
if (Date.parse(expiresAtIso) <= Date.parse(currentTimeIso)) {
|
|
1180
|
+
throw new Error(`${label} must expire after its server current time`);
|
|
1181
|
+
}
|
|
1182
|
+
return Object.freeze({
|
|
1183
|
+
accessKeyId: bundleId(token.access_key_id, `${label}.access_key_id`, 256),
|
|
1184
|
+
currentTimeIso,
|
|
1185
|
+
expiresAtIso,
|
|
1186
|
+
secretAccessKey: opaqueBundleString(
|
|
1187
|
+
token.secret_acess_key,
|
|
1188
|
+
`${label}.secret_acess_key`,
|
|
1189
|
+
4_096,
|
|
1190
|
+
),
|
|
1191
|
+
sessionToken: opaqueBundleString(
|
|
1192
|
+
token.session_token,
|
|
1193
|
+
`${label}.session_token`,
|
|
1194
|
+
16_384,
|
|
1195
|
+
),
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** Parse the full exact upload-auth envelope retained by the read-only capture. */
|
|
1200
|
+
export function parseTikTokVideoUploadTokenProjection(
|
|
1201
|
+
value: unknown,
|
|
1202
|
+
): TikTokVideoUploadTokenProjection {
|
|
1203
|
+
const root = record(value, "TikTok upload-auth projection");
|
|
1204
|
+
exactObjectKeys(root, [
|
|
1205
|
+
"ak",
|
|
1206
|
+
"audio_token_v5",
|
|
1207
|
+
"auth",
|
|
1208
|
+
"extra",
|
|
1209
|
+
"log_pb",
|
|
1210
|
+
"status_code",
|
|
1211
|
+
"status_msg",
|
|
1212
|
+
"store_region",
|
|
1213
|
+
"vframe_token_v5",
|
|
1214
|
+
"video_token_v5",
|
|
1215
|
+
], [], "TikTok upload-auth projection");
|
|
1216
|
+
opaqueBundleString(root.ak, "TikTok upload-auth projection.ak", 256);
|
|
1217
|
+
opaqueBundleString(root.auth, "TikTok upload-auth projection.auth", 1_024);
|
|
1218
|
+
if (root.status_code !== 0 || root.status_msg !== "") {
|
|
1219
|
+
throw new Error("TikTok upload-auth projection did not report exact success");
|
|
1220
|
+
}
|
|
1221
|
+
if (root.store_region !== "US") {
|
|
1222
|
+
throw new Error("TikTok upload-auth projection store_region changed its reviewed value");
|
|
1223
|
+
}
|
|
1224
|
+
const extra = record(root.extra, "TikTok upload-auth projection.extra");
|
|
1225
|
+
exactObjectKeys(extra, [
|
|
1226
|
+
"fatal_item_ids",
|
|
1227
|
+
"logid",
|
|
1228
|
+
"now",
|
|
1229
|
+
], [], "TikTok upload-auth projection.extra");
|
|
1230
|
+
if (!Array.isArray(extra.fatal_item_ids) || extra.fatal_item_ids.length !== 0) {
|
|
1231
|
+
throw new Error("TikTok upload-auth projection.extra.fatal_item_ids must stay empty");
|
|
1232
|
+
}
|
|
1233
|
+
opaqueBundleString(extra.logid, "TikTok upload-auth projection.extra.logid", 256);
|
|
1234
|
+
integer(
|
|
1235
|
+
extra.now,
|
|
1236
|
+
"TikTok upload-auth projection.extra.now",
|
|
1237
|
+
1_000_000_000_000,
|
|
1238
|
+
9_999_999_999_999,
|
|
1239
|
+
);
|
|
1240
|
+
const logPb = record(root.log_pb, "TikTok upload-auth projection.log_pb");
|
|
1241
|
+
exactObjectKeys(logPb, ["impr_id"], [], "TikTok upload-auth projection.log_pb");
|
|
1242
|
+
opaqueBundleString(logPb.impr_id, "TikTok upload-auth projection.log_pb.impr_id", 256);
|
|
1243
|
+
parseTikTokUploadAuthToken(
|
|
1244
|
+
root.audio_token_v5,
|
|
1245
|
+
"TikTok upload-auth audio_token_v5",
|
|
1246
|
+
"tt_audio_mode",
|
|
1247
|
+
);
|
|
1248
|
+
parseTikTokUploadAuthToken(
|
|
1249
|
+
root.vframe_token_v5,
|
|
1250
|
+
"TikTok upload-auth vframe_token_v5",
|
|
1251
|
+
"tiktok-ai-frame",
|
|
1252
|
+
);
|
|
1253
|
+
const token = parseTikTokUploadAuthToken(
|
|
1254
|
+
root.video_token_v5,
|
|
1255
|
+
"TikTok upload-auth video_token_v5",
|
|
1256
|
+
"tiktok",
|
|
1257
|
+
);
|
|
1258
|
+
return Object.freeze({
|
|
1259
|
+
accessKeyId: token.accessKeyId,
|
|
1260
|
+
clockState: "reviewed-utc-second",
|
|
1261
|
+
expiresAtIso: token.expiresAtIso,
|
|
1262
|
+
secretAccessKey: token.secretAccessKey,
|
|
1263
|
+
serverCurrentTimeIso: token.currentTimeIso,
|
|
1264
|
+
sessionToken: token.sessionToken,
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
export type TikTokVideoReviewedServerClock = Readonly<{
|
|
1269
|
+
localAcquiredAtIso: string;
|
|
1270
|
+
serverCurrentTimeIso: string;
|
|
1271
|
+
systemTimeGapMs: number;
|
|
1272
|
+
}>;
|
|
1273
|
+
|
|
1274
|
+
function exactTikTokIsoInstant(value: unknown, label: string): string {
|
|
1275
|
+
const text = opaqueBundleString(value, label, 64);
|
|
1276
|
+
const parsed = new Date(text);
|
|
1277
|
+
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== text) {
|
|
1278
|
+
throw new Error(`${label} must be an exact reviewed ISO instant`);
|
|
1279
|
+
}
|
|
1280
|
+
return text;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Model the pinned SDK's `useServerCurrentTime` branch only after another
|
|
1285
|
+
* boundary has proven date-compatible ISO instants. A gap of at most 60s is
|
|
1286
|
+
* ignored; a larger first gap is retained and later applied to local time.
|
|
1287
|
+
*/
|
|
1288
|
+
export function bindTikTokVideoReviewedServerClock(input: {
|
|
1289
|
+
readonly localAcquiredAtIso: unknown;
|
|
1290
|
+
readonly serverCurrentTimeIso: unknown;
|
|
1291
|
+
readonly useServerCurrentTime: unknown;
|
|
1292
|
+
}): TikTokVideoReviewedServerClock {
|
|
1293
|
+
if (input.useServerCurrentTime !== true) {
|
|
1294
|
+
throw new Error("TikTok reviewed server clock requires useServerCurrentTime true");
|
|
1295
|
+
}
|
|
1296
|
+
const localAcquiredAtIso = exactTikTokIsoInstant(
|
|
1297
|
+
input.localAcquiredAtIso,
|
|
1298
|
+
"TikTok local auth acquisition time",
|
|
1299
|
+
);
|
|
1300
|
+
const serverCurrentTimeIso = exactTikTokIsoInstant(
|
|
1301
|
+
input.serverCurrentTimeIso,
|
|
1302
|
+
"TikTok reviewed server current time",
|
|
1303
|
+
);
|
|
1304
|
+
const gap = new Date(serverCurrentTimeIso).getTime()
|
|
1305
|
+
- new Date(localAcquiredAtIso).getTime();
|
|
1306
|
+
return Object.freeze({
|
|
1307
|
+
localAcquiredAtIso,
|
|
1308
|
+
serverCurrentTimeIso,
|
|
1309
|
+
systemTimeGapMs: Math.abs(gap) > 60_000 ? gap : 0,
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
export function tikTokVideoReviewedSigningTime(
|
|
1314
|
+
clock: TikTokVideoReviewedServerClock,
|
|
1315
|
+
localNowIsoValue: unknown,
|
|
1316
|
+
): string {
|
|
1317
|
+
if (!isRecord(clock)) throw new Error("TikTok reviewed server clock must be exact");
|
|
1318
|
+
exactObjectKeys(clock as JsonRecord, [
|
|
1319
|
+
"localAcquiredAtIso",
|
|
1320
|
+
"serverCurrentTimeIso",
|
|
1321
|
+
"systemTimeGapMs",
|
|
1322
|
+
], [], "TikTok reviewed server clock");
|
|
1323
|
+
exactTikTokIsoInstant(clock.localAcquiredAtIso, "TikTok local auth acquisition time");
|
|
1324
|
+
exactTikTokIsoInstant(clock.serverCurrentTimeIso, "TikTok reviewed server current time");
|
|
1325
|
+
const systemTimeGapMs = integer(
|
|
1326
|
+
clock.systemTimeGapMs,
|
|
1327
|
+
"TikTok reviewed system time gap",
|
|
1328
|
+
-86_400_000,
|
|
1329
|
+
86_400_000,
|
|
1330
|
+
);
|
|
1331
|
+
const localNowIso = exactTikTokIsoInstant(localNowIsoValue, "TikTok local signing time");
|
|
1332
|
+
return new Date(new Date(localNowIso).getTime() + systemTimeGapMs).toISOString();
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
export function buildTikTokApplyUploadInnerRequest(input: {
|
|
1336
|
+
readonly fileSize: number;
|
|
1337
|
+
readonly nonce: string;
|
|
1338
|
+
readonly publicRegion: unknown;
|
|
1339
|
+
}): TikTokBundleRequestProjection {
|
|
1340
|
+
const fileSize = integer(
|
|
1341
|
+
input.fileSize,
|
|
1342
|
+
"TikTok ApplyUploadInner fileSize",
|
|
1343
|
+
24,
|
|
1344
|
+
MAX_TIKTOK_VIDEO_PUBLISH_BYTES,
|
|
1345
|
+
);
|
|
1346
|
+
const nonce = opaqueBundleString(input.nonce, "TikTok ApplyUploadInner nonce", 64);
|
|
1347
|
+
if (!/^[A-Za-z0-9_-]{8,64}$/u.test(nonce)) {
|
|
1348
|
+
throw new Error("TikTok ApplyUploadInner nonce must be bounded URL-safe text");
|
|
1349
|
+
}
|
|
1350
|
+
const region = resolveTikTokVideoUploaderRegion(input.publicRegion);
|
|
1351
|
+
return Object.freeze({
|
|
1352
|
+
method: "GET",
|
|
1353
|
+
path: "/top/v1",
|
|
1354
|
+
query: Object.freeze({
|
|
1355
|
+
Action: "ApplyUploadInner",
|
|
1356
|
+
Version: "2020-11-19",
|
|
1357
|
+
SpaceName: "tiktok",
|
|
1358
|
+
FileType: "video",
|
|
1359
|
+
IsInner: "1",
|
|
1360
|
+
FileSize: String(fileSize),
|
|
1361
|
+
"X-Amz-Expires": "604800",
|
|
1362
|
+
...(region.targetIdc === null ? {} : { "tt-target-idc": region.targetIdc }),
|
|
1363
|
+
s: nonce,
|
|
1364
|
+
device_platform: "web",
|
|
1365
|
+
business_tag: "tiktok_video_submission_web",
|
|
1366
|
+
}),
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
export type TikTokApplyUploadNodeProjection = Readonly<{
|
|
1371
|
+
authorization: string;
|
|
1372
|
+
sessionKey: string;
|
|
1373
|
+
storeUri: string;
|
|
1374
|
+
uploadHost: string;
|
|
1375
|
+
uploadId: string;
|
|
1376
|
+
}>;
|
|
1377
|
+
|
|
1378
|
+
export type TikTokApplyUploadResultProjection = Readonly<{
|
|
1379
|
+
fallback: TikTokApplyUploadNodeProjection | null;
|
|
1380
|
+
primary: TikTokApplyUploadNodeProjection;
|
|
1381
|
+
}>;
|
|
1382
|
+
|
|
1383
|
+
function parseTikTokUploadNode(
|
|
1384
|
+
value: unknown,
|
|
1385
|
+
label: string,
|
|
1386
|
+
): TikTokApplyUploadNodeProjection {
|
|
1387
|
+
const node = record(value, label);
|
|
1388
|
+
exactObjectKeys(
|
|
1389
|
+
node,
|
|
1390
|
+
["StoreInfos", "UploadHost", "SessionKey", "UploadHeader"],
|
|
1391
|
+
[],
|
|
1392
|
+
label,
|
|
1393
|
+
);
|
|
1394
|
+
if (!Array.isArray(node.StoreInfos) || node.StoreInfos.length !== 1) {
|
|
1395
|
+
throw new Error(`${label}.StoreInfos must contain one exact store binding`);
|
|
1396
|
+
}
|
|
1397
|
+
const store = record(node.StoreInfos[0], `${label}.StoreInfos[0]`);
|
|
1398
|
+
exactObjectKeys(store, ["Auth", "StoreUri", "UploadID"], [], `${label}.StoreInfos[0]`);
|
|
1399
|
+
let parsedHeader: unknown = node.UploadHeader;
|
|
1400
|
+
if (typeof parsedHeader === "string") {
|
|
1401
|
+
const uploadHeader = opaqueBundleString(parsedHeader, `${label}.UploadHeader`, 16_384);
|
|
1402
|
+
try {
|
|
1403
|
+
parsedHeader = JSON.parse(uploadHeader) as unknown;
|
|
1404
|
+
} catch {
|
|
1405
|
+
throw new Error(`${label}.UploadHeader must be exact JSON`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
if (!isRecord(parsedHeader) || Object.keys(parsedHeader).length !== 0) {
|
|
1409
|
+
throw new Error(`${label}.UploadHeader must be the reviewed empty header object`);
|
|
1410
|
+
}
|
|
1411
|
+
return Object.freeze({
|
|
1412
|
+
authorization: opaqueBundleString(store.Auth, `${label}.StoreInfos[0].Auth`, 16_384),
|
|
1413
|
+
sessionKey: bundleId(node.SessionKey, `${label}.SessionKey`, 4_096),
|
|
1414
|
+
storeUri: bundleId(store.StoreUri, `${label}.StoreInfos[0].StoreUri`, 2_048),
|
|
1415
|
+
uploadHost: exactHttpsUploadHost(node.UploadHost, `${label}.UploadHost`),
|
|
1416
|
+
uploadId: bundleId(store.UploadID, `${label}.StoreInfos[0].UploadID`, 4_096),
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Parse the exact Result projection described by the public VOD client. A
|
|
1422
|
+
* second node is kept explicitly as fallback evidence and is never selected
|
|
1423
|
+
* automatically.
|
|
1424
|
+
*/
|
|
1425
|
+
export function parseTikTokApplyUploadResultProjection(
|
|
1426
|
+
value: unknown,
|
|
1427
|
+
): TikTokApplyUploadResultProjection {
|
|
1428
|
+
const result = record(value, "TikTok ApplyUploadInner Result projection");
|
|
1429
|
+
exactObjectKeys(result, ["InnerUploadAddress"], [], "TikTok ApplyUploadInner Result projection");
|
|
1430
|
+
const address = record(
|
|
1431
|
+
result.InnerUploadAddress,
|
|
1432
|
+
"TikTok ApplyUploadInner Result.InnerUploadAddress",
|
|
1433
|
+
);
|
|
1434
|
+
exactObjectKeys(address, ["UploadNodes"], [], "TikTok ApplyUploadInner Result.InnerUploadAddress");
|
|
1435
|
+
if (
|
|
1436
|
+
!Array.isArray(address.UploadNodes)
|
|
1437
|
+
|| address.UploadNodes.length < 1
|
|
1438
|
+
|| address.UploadNodes.length > 2
|
|
1439
|
+
) throw new Error("TikTok ApplyUploadInner must contain one primary and at most one explicit fallback node");
|
|
1440
|
+
const primary = parseTikTokUploadNode(
|
|
1441
|
+
address.UploadNodes[0],
|
|
1442
|
+
"TikTok ApplyUploadInner primary node",
|
|
1443
|
+
);
|
|
1444
|
+
const fallback = address.UploadNodes.length === 2
|
|
1445
|
+
? parseTikTokUploadNode(address.UploadNodes[1], "TikTok ApplyUploadInner fallback node")
|
|
1446
|
+
: null;
|
|
1447
|
+
if (
|
|
1448
|
+
fallback !== null
|
|
1449
|
+
&& fallback.uploadHost === primary.uploadHost
|
|
1450
|
+
&& fallback.uploadId === primary.uploadId
|
|
1451
|
+
) throw new Error("TikTok ApplyUploadInner fallback node duplicated the primary transport");
|
|
1452
|
+
return Object.freeze({ primary, fallback });
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
export const TIKTOK_TOS_PART_BYTES = 3_145_728;
|
|
1456
|
+
|
|
1457
|
+
export type TikTokTosPart = Readonly<{
|
|
1458
|
+
byteLength: number;
|
|
1459
|
+
byteOffset: number;
|
|
1460
|
+
partNumber: number;
|
|
1461
|
+
}>;
|
|
1462
|
+
|
|
1463
|
+
export type TikTokTosPartIntegrity = TikTokTosPart & Readonly<{
|
|
1464
|
+
crc32: string;
|
|
1465
|
+
}>;
|
|
1466
|
+
|
|
1467
|
+
export type TikTokTosCompletedTransferCheckpoint = Readonly<{
|
|
1468
|
+
byteLength: number;
|
|
1469
|
+
mediaSha256: string;
|
|
1470
|
+
parts: readonly TikTokTosPartIntegrity[];
|
|
1471
|
+
}>;
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* One immediate TOS transport projection. Authorization is response-selected
|
|
1475
|
+
* ephemeral material and must never be persisted in a plan, receipt, log, or
|
|
1476
|
+
* fixture. The origin, object path, and authorization all come from the same
|
|
1477
|
+
* strictly parsed ApplyUploadInner node.
|
|
1478
|
+
*/
|
|
1479
|
+
export type TikTokTosRequestProjection = TikTokBundleRequestProjection & Readonly<{
|
|
1480
|
+
headers: Readonly<Record<string, string>>;
|
|
1481
|
+
origin: string;
|
|
1482
|
+
}>;
|
|
1483
|
+
|
|
1484
|
+
const TIKTOK_TOS_CRC32_TABLE = Object.freeze(Array.from(
|
|
1485
|
+
{ length: 256 },
|
|
1486
|
+
(_, index) => {
|
|
1487
|
+
let value = index;
|
|
1488
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
1489
|
+
value = (value >>> 1) ^ (value & 1 ? 0xedb8_8320 : 0);
|
|
1490
|
+
}
|
|
1491
|
+
return value >>> 0;
|
|
1492
|
+
},
|
|
1493
|
+
));
|
|
1494
|
+
|
|
1495
|
+
export function planTikTokTosParts(byteLengthValue: unknown): readonly TikTokTosPart[] {
|
|
1496
|
+
const byteLength = integer(
|
|
1497
|
+
byteLengthValue,
|
|
1498
|
+
"TikTok TOS byte length",
|
|
1499
|
+
24,
|
|
1500
|
+
MAX_TIKTOK_VIDEO_PUBLISH_BYTES,
|
|
1501
|
+
);
|
|
1502
|
+
const parts: TikTokTosPart[] = [];
|
|
1503
|
+
for (let byteOffset = 0, partNumber = 1; byteOffset < byteLength; partNumber += 1) {
|
|
1504
|
+
const partByteLength = Math.min(TIKTOK_TOS_PART_BYTES, byteLength - byteOffset);
|
|
1505
|
+
parts.push(Object.freeze({ byteLength: partByteLength, byteOffset, partNumber }));
|
|
1506
|
+
byteOffset += partByteLength;
|
|
1507
|
+
}
|
|
1508
|
+
return Object.freeze(parts);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** Standard IEEE CRC-32 rendered as the lowercase eight-hex TOS value. */
|
|
1512
|
+
export function tikTokTosCrc32(bytes: Uint8Array): string {
|
|
1513
|
+
if (!(bytes instanceof Uint8Array)) throw new Error("TikTok TOS CRC32 input must be bytes");
|
|
1514
|
+
let crc = 0xffff_ffff;
|
|
1515
|
+
for (const byte of bytes) {
|
|
1516
|
+
crc = (crc >>> 8) ^ TIKTOK_TOS_CRC32_TABLE[(crc ^ byte) & 0xff]!;
|
|
1517
|
+
}
|
|
1518
|
+
return ((crc ^ 0xffff_ffff) >>> 0).toString(16).padStart(8, "0");
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
export function tikTokVideoSha256(bytes: Uint8Array): string {
|
|
1522
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
1523
|
+
throw new Error("TikTok video SHA-256 input must be exact bytes");
|
|
1524
|
+
}
|
|
1525
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* Bind the exact local bytes to the bundle-proven contiguous multipart plan.
|
|
1530
|
+
* This contains no upload identifier or credential and grants no dispatch or
|
|
1531
|
+
* resume authority.
|
|
1532
|
+
*/
|
|
1533
|
+
export function planTikTokTosPartIntegrity(
|
|
1534
|
+
bytes: Uint8Array,
|
|
1535
|
+
): readonly TikTokTosPartIntegrity[] {
|
|
1536
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
1537
|
+
throw new Error("TikTok TOS integrity planning requires exact bytes");
|
|
1538
|
+
}
|
|
1539
|
+
return Object.freeze(planTikTokTosParts(bytes.byteLength).map((part) =>
|
|
1540
|
+
Object.freeze({
|
|
1541
|
+
...part,
|
|
1542
|
+
crc32: tikTokTosCrc32(bytes.subarray(
|
|
1543
|
+
part.byteOffset,
|
|
1544
|
+
part.byteOffset + part.byteLength,
|
|
1545
|
+
)),
|
|
1546
|
+
})));
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Accept a durable transfer-complete checkpoint only when every planned part
|
|
1551
|
+
* has one strict CRC acknowledgement. A partial prefix is intentionally not a
|
|
1552
|
+
* resumable state because the current evidence proves no retry/idempotency
|
|
1553
|
+
* contract for TOS transfer, finish, commit, or project publication. Even a
|
|
1554
|
+
* complete result is local integrity evidence only: it is not bound to an
|
|
1555
|
+
* upload ID and therefore never authorizes finish, commit, or publication.
|
|
1556
|
+
*/
|
|
1557
|
+
export function verifyTikTokTosCompletedTransfer(
|
|
1558
|
+
bytes: Uint8Array,
|
|
1559
|
+
transferResponses: unknown,
|
|
1560
|
+
): TikTokTosCompletedTransferCheckpoint {
|
|
1561
|
+
const parts = planTikTokTosPartIntegrity(bytes);
|
|
1562
|
+
if (!Array.isArray(transferResponses) || transferResponses.length !== parts.length) {
|
|
1563
|
+
throw new Error(
|
|
1564
|
+
"TikTok TOS transfer checkpoint must acknowledge every planned part; partial transfer remains indeterminate",
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
for (const [index, part] of parts.entries()) {
|
|
1568
|
+
parseTikTokTosTransferResponse(transferResponses[index], part.crc32);
|
|
1569
|
+
}
|
|
1570
|
+
return Object.freeze({
|
|
1571
|
+
byteLength: bytes.byteLength,
|
|
1572
|
+
mediaSha256: tikTokVideoSha256(bytes),
|
|
1573
|
+
parts,
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function tosObjectPath(value: unknown): string {
|
|
1578
|
+
const oid = opaqueBundleString(value, "TikTok TOS object ID", 2_048);
|
|
1579
|
+
const segments = oid.split("/");
|
|
1580
|
+
if (
|
|
1581
|
+
segments.length > 32
|
|
1582
|
+
|| segments.some((segment) =>
|
|
1583
|
+
segment === "."
|
|
1584
|
+
|| segment === ".."
|
|
1585
|
+
|| !/^[A-Za-z0-9._~:@+=-]{1,256}$/u.test(segment))
|
|
1586
|
+
) throw new Error("TikTok TOS object ID must contain only bounded provider path segments");
|
|
1587
|
+
return `/upload/v1/${segments.map((segment) => encodeURIComponent(segment)).join("/")}`;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function tosUploadId(value: unknown): string {
|
|
1591
|
+
return bundleId(value, "TikTok TOS upload ID", 4_096);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function revalidateTikTokApplyUploadNodeProjection(
|
|
1595
|
+
value: unknown,
|
|
1596
|
+
): TikTokApplyUploadNodeProjection {
|
|
1597
|
+
const node = record(value, "TikTok selected ApplyUploadInner node");
|
|
1598
|
+
exactObjectKeys(node, [
|
|
1599
|
+
"authorization",
|
|
1600
|
+
"sessionKey",
|
|
1601
|
+
"storeUri",
|
|
1602
|
+
"uploadHost",
|
|
1603
|
+
"uploadId",
|
|
1604
|
+
], [], "TikTok selected ApplyUploadInner node");
|
|
1605
|
+
return Object.freeze({
|
|
1606
|
+
authorization: opaqueBundleString(
|
|
1607
|
+
node.authorization,
|
|
1608
|
+
"TikTok selected TOS authorization",
|
|
1609
|
+
16_384,
|
|
1610
|
+
),
|
|
1611
|
+
sessionKey: bundleId(node.sessionKey, "TikTok selected TOS session key", 4_096),
|
|
1612
|
+
storeUri: bundleId(node.storeUri, "TikTok selected TOS store URI", 2_048),
|
|
1613
|
+
uploadHost: exactHttpsUploadHost(node.uploadHost, "TikTok selected TOS upload host"),
|
|
1614
|
+
uploadId: tosUploadId(node.uploadId),
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
function tikTokTosHeaders(
|
|
1619
|
+
node: TikTokApplyUploadNodeProjection,
|
|
1620
|
+
subjectIdValue: unknown,
|
|
1621
|
+
crc32?: string,
|
|
1622
|
+
): Readonly<Record<string, string>> {
|
|
1623
|
+
return Object.freeze({
|
|
1624
|
+
Authorization: node.authorization,
|
|
1625
|
+
"X-Storage-U": encodeURIComponent(decimalId(
|
|
1626
|
+
subjectIdValue,
|
|
1627
|
+
"TikTok current upload subject ID",
|
|
1628
|
+
)),
|
|
1629
|
+
...(crc32 === undefined ? {} : { "Content-CRC32": crc32 }),
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
export function buildTikTokTosInitRequest(input: {
|
|
1634
|
+
readonly node: unknown;
|
|
1635
|
+
readonly subjectId: unknown;
|
|
1636
|
+
}): TikTokTosRequestProjection {
|
|
1637
|
+
if (!isRecord(input)) throw new Error("TikTok TOS init input must be an object");
|
|
1638
|
+
exactObjectKeys(input as JsonRecord, ["node", "subjectId"], [], "TikTok TOS init input");
|
|
1639
|
+
const node = revalidateTikTokApplyUploadNodeProjection(input.node);
|
|
1640
|
+
return Object.freeze({
|
|
1641
|
+
headers: tikTokTosHeaders(node, input.subjectId),
|
|
1642
|
+
method: "POST",
|
|
1643
|
+
origin: node.uploadHost,
|
|
1644
|
+
path: tosObjectPath(node.storeUri),
|
|
1645
|
+
query: Object.freeze({ uploadmode: "part", phase: "init" }),
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
export function buildTikTokTosTransferRequest(input: {
|
|
1650
|
+
readonly crc32: string;
|
|
1651
|
+
readonly node: unknown;
|
|
1652
|
+
readonly part: TikTokTosPart;
|
|
1653
|
+
readonly subjectId: unknown;
|
|
1654
|
+
readonly uploadId: unknown;
|
|
1655
|
+
}): TikTokTosRequestProjection {
|
|
1656
|
+
if (!isRecord(input)) throw new Error("TikTok TOS transfer input must be an object");
|
|
1657
|
+
exactObjectKeys(
|
|
1658
|
+
input as JsonRecord,
|
|
1659
|
+
["crc32", "node", "part", "subjectId", "uploadId"],
|
|
1660
|
+
[],
|
|
1661
|
+
"TikTok TOS transfer input",
|
|
1662
|
+
);
|
|
1663
|
+
if (!isRecord(input.part)) throw new Error("TikTok TOS transfer part must be an object");
|
|
1664
|
+
exactObjectKeys(
|
|
1665
|
+
input.part as unknown as JsonRecord,
|
|
1666
|
+
["byteLength", "byteOffset", "partNumber"],
|
|
1667
|
+
[],
|
|
1668
|
+
"TikTok TOS transfer part",
|
|
1669
|
+
);
|
|
1670
|
+
const node = revalidateTikTokApplyUploadNodeProjection(input.node);
|
|
1671
|
+
const partNumber = integer(input.part.partNumber, "TikTok TOS part number", 1, 1_024);
|
|
1672
|
+
const byteOffset = integer(
|
|
1673
|
+
input.part.byteOffset,
|
|
1674
|
+
"TikTok TOS part offset",
|
|
1675
|
+
0,
|
|
1676
|
+
MAX_TIKTOK_VIDEO_PUBLISH_BYTES - 1,
|
|
1677
|
+
);
|
|
1678
|
+
integer(input.part.byteLength, "TikTok TOS part byte length", 1, TIKTOK_TOS_PART_BYTES);
|
|
1679
|
+
const crc32 = opaqueBundleString(input.crc32, "TikTok TOS Content-CRC32", 8).toLowerCase();
|
|
1680
|
+
if (!/^[0-9a-f]{8}$/u.test(crc32)) throw new Error("TikTok TOS Content-CRC32 must be eight hexadecimal digits");
|
|
1681
|
+
return Object.freeze({
|
|
1682
|
+
headers: tikTokTosHeaders(node, input.subjectId, crc32),
|
|
1683
|
+
method: "POST",
|
|
1684
|
+
origin: node.uploadHost,
|
|
1685
|
+
path: tosObjectPath(node.storeUri),
|
|
1686
|
+
query: Object.freeze({
|
|
1687
|
+
uploadid: tosUploadId(input.uploadId),
|
|
1688
|
+
part_number: String(partNumber),
|
|
1689
|
+
phase: "transfer",
|
|
1690
|
+
part_offset: String(byteOffset),
|
|
1691
|
+
}),
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
export function buildTikTokTosFinishRequest(input: {
|
|
1696
|
+
readonly byteLength: unknown;
|
|
1697
|
+
readonly node: unknown;
|
|
1698
|
+
readonly subjectId: unknown;
|
|
1699
|
+
readonly uploadId: unknown;
|
|
1700
|
+
}): TikTokTosRequestProjection {
|
|
1701
|
+
if (!isRecord(input)) throw new Error("TikTok TOS finish input must be an object");
|
|
1702
|
+
exactObjectKeys(
|
|
1703
|
+
input as JsonRecord,
|
|
1704
|
+
["byteLength", "node", "subjectId", "uploadId"],
|
|
1705
|
+
[],
|
|
1706
|
+
"TikTok TOS finish input",
|
|
1707
|
+
);
|
|
1708
|
+
const node = revalidateTikTokApplyUploadNodeProjection(input.node);
|
|
1709
|
+
return Object.freeze({
|
|
1710
|
+
headers: tikTokTosHeaders(node, input.subjectId),
|
|
1711
|
+
method: "POST",
|
|
1712
|
+
origin: node.uploadHost,
|
|
1713
|
+
path: tosObjectPath(node.storeUri),
|
|
1714
|
+
query: Object.freeze({
|
|
1715
|
+
uploadmode: "part",
|
|
1716
|
+
phase: "finish",
|
|
1717
|
+
size: String(integer(
|
|
1718
|
+
input.byteLength,
|
|
1719
|
+
"TikTok TOS finish byte length",
|
|
1720
|
+
24,
|
|
1721
|
+
MAX_TIKTOK_VIDEO_PUBLISH_BYTES,
|
|
1722
|
+
)),
|
|
1723
|
+
uploadid: tosUploadId(input.uploadId),
|
|
1724
|
+
}),
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function tosSuccessData(value: unknown, field: string, label: string): JsonRecord {
|
|
1729
|
+
const root = record(value, label);
|
|
1730
|
+
exactObjectKeys(root, ["code", "data"], ["message"], label);
|
|
1731
|
+
if (root.code !== 2000) throw new Error(`${label} did not return TOS code 2000`);
|
|
1732
|
+
if (root.message !== undefined) boundedText(root.message, `${label}.message`, 2_048);
|
|
1733
|
+
const data = record(root.data, `${label}.data`);
|
|
1734
|
+
exactObjectKeys(data, [field], [], `${label}.data`);
|
|
1735
|
+
return data;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
export function parseTikTokTosInitResponse(value: unknown): Readonly<{ uploadId: string }> {
|
|
1739
|
+
const data = tosSuccessData(value, "uploadid", "TikTok TOS init response");
|
|
1740
|
+
return Object.freeze({ uploadId: tosUploadId(data.uploadid) });
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
export function parseTikTokTosTransferResponse(
|
|
1744
|
+
value: unknown,
|
|
1745
|
+
expectedCrc32Value: unknown,
|
|
1746
|
+
): Readonly<{ crc32: string }> {
|
|
1747
|
+
const expectedCrc32 = opaqueBundleString(
|
|
1748
|
+
expectedCrc32Value,
|
|
1749
|
+
"expected TikTok TOS CRC32",
|
|
1750
|
+
8,
|
|
1751
|
+
).toLowerCase();
|
|
1752
|
+
if (!/^[0-9a-f]{8}$/u.test(expectedCrc32)) {
|
|
1753
|
+
throw new Error("expected TikTok TOS CRC32 must be eight hexadecimal digits");
|
|
1754
|
+
}
|
|
1755
|
+
const data = tosSuccessData(value, "crc32", "TikTok TOS transfer response");
|
|
1756
|
+
const crc32 = opaqueBundleString(data.crc32, "TikTok TOS transfer response.data.crc32", 8).toLowerCase();
|
|
1757
|
+
if (!/^[0-9a-f]{8}$/u.test(crc32) || crc32 !== expectedCrc32) {
|
|
1758
|
+
throw new Error("TikTok TOS transfer response did not bind the uploaded part CRC32");
|
|
1759
|
+
}
|
|
1760
|
+
return Object.freeze({ crc32 });
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
export function parseTikTokTosFinishResponse(value: unknown): Readonly<{ key: string }> {
|
|
1764
|
+
const data = tosSuccessData(value, "key", "TikTok TOS finish response");
|
|
1765
|
+
return Object.freeze({ key: bundleId(data.key, "TikTok TOS finish response.data.key", 4_096) });
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
export function buildTikTokCommitUploadInnerRequest(
|
|
1769
|
+
sessionKeyValue: unknown,
|
|
1770
|
+
publicRegionValue: unknown,
|
|
1771
|
+
): TikTokBundleRequestProjection {
|
|
1772
|
+
const sessionKey = bundleId(sessionKeyValue, "TikTok CommitUploadInner SessionKey", 4_096);
|
|
1773
|
+
const region = resolveTikTokVideoUploaderRegion(publicRegionValue);
|
|
1774
|
+
return Object.freeze({
|
|
1775
|
+
method: "POST",
|
|
1776
|
+
path: "/top/v1",
|
|
1777
|
+
query: Object.freeze({
|
|
1778
|
+
Action: "CommitUploadInner",
|
|
1779
|
+
Version: "2020-11-19",
|
|
1780
|
+
SpaceName: "tiktok",
|
|
1781
|
+
"X-Amz-Expires": "604800",
|
|
1782
|
+
...(region.targetIdc === null ? {} : { "tt-target-idc": region.targetIdc }),
|
|
1783
|
+
}),
|
|
1784
|
+
body: Object.freeze({
|
|
1785
|
+
SessionKey: sessionKey,
|
|
1786
|
+
Functions: Object.freeze([Object.freeze({ name: "GetMeta" })]),
|
|
1787
|
+
}),
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
export type TikTokVideoTopRequestInput =
|
|
1792
|
+
| Readonly<{
|
|
1793
|
+
fileSize: unknown;
|
|
1794
|
+
kind: "apply";
|
|
1795
|
+
nonce: unknown;
|
|
1796
|
+
}>
|
|
1797
|
+
| Readonly<{
|
|
1798
|
+
kind: "commit";
|
|
1799
|
+
sessionKey: unknown;
|
|
1800
|
+
}>;
|
|
1801
|
+
|
|
1802
|
+
export type TikTokSignedVideoTopRequest = Readonly<{
|
|
1803
|
+
bodyText: string | null;
|
|
1804
|
+
headers: Readonly<Record<string, string>>;
|
|
1805
|
+
request: TikTokBundleRequestProjection;
|
|
1806
|
+
signingTimeUnixMs: number;
|
|
1807
|
+
url: string;
|
|
1808
|
+
}>;
|
|
1809
|
+
|
|
1810
|
+
function tikTokAwsEncode(value: string): string {
|
|
1811
|
+
return encodeURIComponent(value).replace(/[!'()*]/gu, (character) =>
|
|
1812
|
+
`%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
function tikTokCanonicalQuery(query: Readonly<Record<string, string>>): string {
|
|
1816
|
+
return Object.keys(query).sort().map((name) =>
|
|
1817
|
+
`${tikTokAwsEncode(name)}=${tikTokAwsEncode(query[name]!)}`
|
|
1818
|
+
).join("&");
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function tikTokHmacSha256(key: string | Uint8Array, value: string): Buffer {
|
|
1822
|
+
return createHmac("sha256", key).update(value, "utf8").digest();
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function tikTokAmzDate(unixMs: number): string {
|
|
1826
|
+
const date = new Date(unixMs);
|
|
1827
|
+
if (!Number.isFinite(date.getTime())) throw new Error("TikTok upload signing time is invalid");
|
|
1828
|
+
return date.toISOString().replace(/[:-]|\.\d{3}/gu, "");
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* Deterministically sign only one code-owned ApplyUploadInner or
|
|
1833
|
+
* CommitUploadInner projection. Host selection comes from the pinned public
|
|
1834
|
+
* region map, while credential scope uses `US-TTP`; no caller-selected URL,
|
|
1835
|
+
* region, request, headers, or query is accepted.
|
|
1836
|
+
*/
|
|
1837
|
+
export function signTikTokVideoTopRequest(input: {
|
|
1838
|
+
readonly publicRegion: unknown;
|
|
1839
|
+
readonly request: TikTokVideoTopRequestInput;
|
|
1840
|
+
readonly reviewedSigningTimeIso: unknown;
|
|
1841
|
+
readonly token: TikTokVideoUploadTokenProjection;
|
|
1842
|
+
}): TikTokSignedVideoTopRequest {
|
|
1843
|
+
if (!isRecord(input.request)) throw new Error("TikTok signed top request input must be exact");
|
|
1844
|
+
const request = input.request.kind === "apply"
|
|
1845
|
+
? (() => {
|
|
1846
|
+
exactObjectKeys(
|
|
1847
|
+
input.request as JsonRecord,
|
|
1848
|
+
["fileSize", "kind", "nonce"],
|
|
1849
|
+
[],
|
|
1850
|
+
"TikTok signed ApplyUploadInner input",
|
|
1851
|
+
);
|
|
1852
|
+
return buildTikTokApplyUploadInnerRequest({
|
|
1853
|
+
fileSize: input.request.fileSize as number,
|
|
1854
|
+
nonce: input.request.nonce as string,
|
|
1855
|
+
publicRegion: input.publicRegion,
|
|
1856
|
+
});
|
|
1857
|
+
})()
|
|
1858
|
+
: input.request.kind === "commit"
|
|
1859
|
+
? (() => {
|
|
1860
|
+
exactObjectKeys(
|
|
1861
|
+
input.request as JsonRecord,
|
|
1862
|
+
["kind", "sessionKey"],
|
|
1863
|
+
[],
|
|
1864
|
+
"TikTok signed CommitUploadInner input",
|
|
1865
|
+
);
|
|
1866
|
+
return buildTikTokCommitUploadInnerRequest(
|
|
1867
|
+
input.request.sessionKey,
|
|
1868
|
+
input.publicRegion,
|
|
1869
|
+
);
|
|
1870
|
+
})()
|
|
1871
|
+
: (() => {
|
|
1872
|
+
throw new Error("TikTok signed top request kind is not bundle-proven");
|
|
1873
|
+
})();
|
|
1874
|
+
const region = resolveTikTokVideoUploaderRegion(input.publicRegion);
|
|
1875
|
+
if (!isRecord(input.token)) throw new Error("TikTok upload signing token must be exact");
|
|
1876
|
+
exactObjectKeys(input.token as JsonRecord, [
|
|
1877
|
+
"accessKeyId",
|
|
1878
|
+
"clockState",
|
|
1879
|
+
"expiresAtIso",
|
|
1880
|
+
"secretAccessKey",
|
|
1881
|
+
"serverCurrentTimeIso",
|
|
1882
|
+
"sessionToken",
|
|
1883
|
+
], [], "TikTok upload signing token");
|
|
1884
|
+
if (input.token.clockState !== "reviewed-utc-second") {
|
|
1885
|
+
throw new Error("TikTok upload signing token clock state changed shape");
|
|
1886
|
+
}
|
|
1887
|
+
bundleId(input.token.accessKeyId, "TikTok upload signing access key", 256);
|
|
1888
|
+
exactTikTokIsoInstant(input.token.expiresAtIso, "TikTok upload signing expiry");
|
|
1889
|
+
opaqueBundleString(input.token.secretAccessKey, "TikTok upload signing secret key", 4_096);
|
|
1890
|
+
exactTikTokIsoInstant(
|
|
1891
|
+
input.token.serverCurrentTimeIso,
|
|
1892
|
+
"TikTok upload signing server current time",
|
|
1893
|
+
);
|
|
1894
|
+
opaqueBundleString(input.token.sessionToken, "TikTok upload signing session token", 16_384);
|
|
1895
|
+
const reviewedSigningTimeIso = exactTikTokIsoInstant(
|
|
1896
|
+
input.reviewedSigningTimeIso,
|
|
1897
|
+
"TikTok reviewed upload signing time",
|
|
1898
|
+
);
|
|
1899
|
+
const signingTimeUnixMs = new Date(reviewedSigningTimeIso).getTime();
|
|
1900
|
+
const amzDate = tikTokAmzDate(signingTimeUnixMs);
|
|
1901
|
+
const shortDate = amzDate.slice(0, 8);
|
|
1902
|
+
const bodyText = request.body === undefined ? null : JSON.stringify(request.body);
|
|
1903
|
+
const bodyHash = createHash("sha256").update(bodyText ?? "", "utf8").digest("hex");
|
|
1904
|
+
const unsignedHeaders: Record<string, string> = {
|
|
1905
|
+
"x-amz-date": amzDate,
|
|
1906
|
+
"x-amz-security-token": input.token.sessionToken,
|
|
1907
|
+
...(bodyText === null ? {} : { "x-amz-content-sha256": bodyHash }),
|
|
1908
|
+
};
|
|
1909
|
+
const signedHeaderNames = Object.keys(unsignedHeaders).sort();
|
|
1910
|
+
const canonicalHeaders = signedHeaderNames.map((name) =>
|
|
1911
|
+
`${name}:${unsignedHeaders[name]!.replace(/\s+/gu, " ").trim()}`
|
|
1912
|
+
).join("\n");
|
|
1913
|
+
const signedHeaders = signedHeaderNames.join(";");
|
|
1914
|
+
const canonicalRequest = [
|
|
1915
|
+
request.method,
|
|
1916
|
+
request.path,
|
|
1917
|
+
tikTokCanonicalQuery(request.query),
|
|
1918
|
+
`${canonicalHeaders}\n`,
|
|
1919
|
+
signedHeaders,
|
|
1920
|
+
bodyHash,
|
|
1921
|
+
].join("\n");
|
|
1922
|
+
const credentialScope = `${shortDate}/${region.signingRegion}/vod/aws4_request`;
|
|
1923
|
+
const stringToSign = [
|
|
1924
|
+
"AWS4-HMAC-SHA256",
|
|
1925
|
+
amzDate,
|
|
1926
|
+
credentialScope,
|
|
1927
|
+
createHash("sha256").update(canonicalRequest, "utf8").digest("hex"),
|
|
1928
|
+
].join("\n");
|
|
1929
|
+
const dateKey = tikTokHmacSha256(`AWS4${input.token.secretAccessKey}`, shortDate);
|
|
1930
|
+
const regionKey = tikTokHmacSha256(dateKey, region.signingRegion);
|
|
1931
|
+
const serviceKey = tikTokHmacSha256(regionKey, "vod");
|
|
1932
|
+
const signingKey = tikTokHmacSha256(serviceKey, "aws4_request");
|
|
1933
|
+
const signature = tikTokHmacSha256(signingKey, stringToSign).toString("hex");
|
|
1934
|
+
const url = new URL(region.videoUrl);
|
|
1935
|
+
for (const [name, value] of Object.entries(request.query)) {
|
|
1936
|
+
url.searchParams.set(name, value);
|
|
1937
|
+
}
|
|
1938
|
+
return Object.freeze({
|
|
1939
|
+
bodyText,
|
|
1940
|
+
headers: Object.freeze({
|
|
1941
|
+
...unsignedHeaders,
|
|
1942
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${input.token.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
1943
|
+
}),
|
|
1944
|
+
request,
|
|
1945
|
+
signingTimeUnixMs,
|
|
1946
|
+
url: url.href,
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
export type TikTokCommitUploadResultProjection = Readonly<{
|
|
1951
|
+
bitrate: number | null;
|
|
1952
|
+
codec: string | null;
|
|
1953
|
+
duration: number | null;
|
|
1954
|
+
format: string | null;
|
|
1955
|
+
height: number | null;
|
|
1956
|
+
uri: string;
|
|
1957
|
+
videoId: string;
|
|
1958
|
+
width: number | null;
|
|
1959
|
+
}>;
|
|
1960
|
+
|
|
1961
|
+
/** Parse the single committed-result projection used by Studio after commit. */
|
|
1962
|
+
export function parseTikTokCommitUploadResultProjection(
|
|
1963
|
+
value: unknown,
|
|
1964
|
+
): TikTokCommitUploadResultProjection {
|
|
1965
|
+
const result = record(value, "TikTok CommitUploadInner Result projection");
|
|
1966
|
+
exactObjectKeys(result, ["Results"], [], "TikTok CommitUploadInner Result projection");
|
|
1967
|
+
if (!Array.isArray(result.Results) || result.Results.length !== 1) {
|
|
1968
|
+
throw new Error("TikTok CommitUploadInner Result must bind one committed video");
|
|
1969
|
+
}
|
|
1970
|
+
const item = record(result.Results[0], "TikTok CommitUploadInner Result.Results[0]");
|
|
1971
|
+
exactObjectKeys(item, ["Vid", "Uri"], ["VideoMeta"], "TikTok CommitUploadInner Result.Results[0]");
|
|
1972
|
+
const meta = item.VideoMeta === undefined
|
|
1973
|
+
? null
|
|
1974
|
+
: record(item.VideoMeta, "TikTok CommitUploadInner VideoMeta");
|
|
1975
|
+
if (meta !== null) {
|
|
1976
|
+
exactObjectKeys(
|
|
1977
|
+
meta,
|
|
1978
|
+
["Width", "Height", "Duration", "Format", "Codec", "Bitrate"],
|
|
1979
|
+
[],
|
|
1980
|
+
"TikTok CommitUploadInner VideoMeta",
|
|
1981
|
+
);
|
|
1982
|
+
}
|
|
1983
|
+
return Object.freeze({
|
|
1984
|
+
bitrate: meta === null ? null : integer(meta.Bitrate, "TikTok committed video bitrate", 0),
|
|
1985
|
+
codec: meta === null ? null : opaqueBundleString(meta.Codec, "TikTok committed video codec", 128),
|
|
1986
|
+
duration: meta === null ? null : integer(meta.Duration, "TikTok committed video duration", 0, 86_400_000),
|
|
1987
|
+
format: meta === null ? null : opaqueBundleString(meta.Format, "TikTok committed video format", 128),
|
|
1988
|
+
height: meta === null ? null : integer(meta.Height, "TikTok committed video height", 1, 65_535),
|
|
1989
|
+
uri: bundleId(item.Uri, "TikTok committed video Uri", 2_048),
|
|
1990
|
+
videoId: bundleId(item.Vid, "TikTok committed video Vid", 2_048),
|
|
1991
|
+
width: meta === null ? null : integer(meta.Width, "TikTok committed video width", 1, 65_535),
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
export const TIKTOK_VIDEO_TRANSCODE_POLL_INTERVAL_MS = 1_000;
|
|
1996
|
+
export const TIKTOK_VIDEO_TRANSCODE_POLL_TIMEOUT_MS = 3_600_000;
|
|
1997
|
+
|
|
1998
|
+
export type TikTokVideoTranscodeState =
|
|
1999
|
+
| "unknown"
|
|
2000
|
+
| "init"
|
|
2001
|
+
| "in-progress"
|
|
2002
|
+
| "success"
|
|
2003
|
+
| "failed";
|
|
2004
|
+
|
|
2005
|
+
const TIKTOK_VIDEO_TRANSCODE_STATES = Object.freeze([
|
|
2006
|
+
"unknown",
|
|
2007
|
+
"init",
|
|
2008
|
+
"in-progress",
|
|
2009
|
+
"success",
|
|
2010
|
+
"failed",
|
|
2011
|
+
] as const satisfies readonly TikTokVideoTranscodeState[]);
|
|
2012
|
+
|
|
2013
|
+
const TIKTOK_TRANSCODE_ENABLE_RUNTIME_SECURITY = Object.freeze({
|
|
2014
|
+
acrawler: "not-listed-for-route" as const,
|
|
2015
|
+
antiCsrf: "required" as const,
|
|
2016
|
+
credentials: "include" as const,
|
|
2017
|
+
csrfHeader: "in-origin-ephemeral" as const,
|
|
2018
|
+
execution: "authenticated-in-origin-studio-session" as const,
|
|
2019
|
+
verifyFp: "not-requested-by-base-query" as const,
|
|
2020
|
+
zti: "not-listed-for-route" as const,
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
const TIKTOK_UNLISTED_STUDIO_RUNTIME_SECURITY = Object.freeze({
|
|
2024
|
+
acrawler: "not-listed-for-route" as const,
|
|
2025
|
+
antiCsrf: "not-listed-for-route" as const,
|
|
2026
|
+
credentials: "include" as const,
|
|
2027
|
+
csrfHeader: "not-explicit-for-route" as const,
|
|
2028
|
+
execution: "authenticated-in-origin-studio-session" as const,
|
|
2029
|
+
verifyFp: "not-requested-by-base-query" as const,
|
|
2030
|
+
zti: "not-listed-for-route" as const,
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
function tikTokStudioBaseQuery(
|
|
2034
|
+
publicRegionValue: unknown,
|
|
2035
|
+
): Readonly<Record<string, string>> {
|
|
2036
|
+
const region = resolveTikTokVideoUploaderRegion(publicRegionValue);
|
|
2037
|
+
return Object.freeze({
|
|
2038
|
+
aid: "1988",
|
|
2039
|
+
...(region.targetIdc === null ? {} : { "tt-target-idc": region.targetIdc }),
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/** Build the bundle-proven transcode-enablement projection without a CSRF value. */
|
|
2044
|
+
export function buildTikTokVideoTranscodeEnableRequestProjection(input: {
|
|
2045
|
+
readonly publicRegion: unknown;
|
|
2046
|
+
readonly videoId: unknown;
|
|
2047
|
+
}): TikTokStudioRequestProjection {
|
|
2048
|
+
if (!isRecord(input)) throw new Error("TikTok transcode enable input must be an object");
|
|
2049
|
+
exactObjectKeys(
|
|
2050
|
+
input as JsonRecord,
|
|
2051
|
+
["publicRegion", "videoId"],
|
|
2052
|
+
[],
|
|
2053
|
+
"TikTok transcode enable input",
|
|
2054
|
+
);
|
|
2055
|
+
return Object.freeze({
|
|
2056
|
+
method: "POST",
|
|
2057
|
+
path: "/api/v1/video/transcode/enable/",
|
|
2058
|
+
query: Object.freeze({
|
|
2059
|
+
video_id: bundleId(input.videoId, "TikTok transcode video_id", 2_048),
|
|
2060
|
+
...tikTokStudioBaseQuery(input.publicRegion),
|
|
2061
|
+
}),
|
|
2062
|
+
runtimeSecurity: TIKTOK_TRANSCODE_ENABLE_RUNTIME_SECURITY,
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/**
|
|
2067
|
+
* Build one bundle-proven transcode-result poll. The raw authenticated response
|
|
2068
|
+
* envelope remains capture-required; this projection never guesses it.
|
|
2069
|
+
*/
|
|
2070
|
+
export function buildTikTokVideoTranscodeResultRequestProjection(input: {
|
|
2071
|
+
readonly durationSeconds: unknown;
|
|
2072
|
+
readonly fileKey: unknown;
|
|
2073
|
+
readonly height: unknown;
|
|
2074
|
+
readonly publicRegion: unknown;
|
|
2075
|
+
readonly videoId: unknown;
|
|
2076
|
+
readonly width: unknown;
|
|
2077
|
+
}): TikTokStudioRequestProjection {
|
|
2078
|
+
if (!isRecord(input)) throw new Error("TikTok transcode result input must be an object");
|
|
2079
|
+
exactObjectKeys(input as JsonRecord, [
|
|
2080
|
+
"durationSeconds",
|
|
2081
|
+
"fileKey",
|
|
2082
|
+
"height",
|
|
2083
|
+
"publicRegion",
|
|
2084
|
+
"videoId",
|
|
2085
|
+
"width",
|
|
2086
|
+
], [], "TikTok transcode result input");
|
|
2087
|
+
if (
|
|
2088
|
+
typeof input.durationSeconds !== "number"
|
|
2089
|
+
|| !Number.isFinite(input.durationSeconds)
|
|
2090
|
+
|| input.durationSeconds < 0
|
|
2091
|
+
|| input.durationSeconds > 86_400
|
|
2092
|
+
) throw new Error("TikTok transcode durationSeconds must be finite and between 0 and 86400");
|
|
2093
|
+
const videoInfo = Object.freeze({
|
|
2094
|
+
file_key: bundleId(input.fileKey, "TikTok transcode file_key", 2_048),
|
|
2095
|
+
video_id: bundleId(input.videoId, "TikTok transcode video_id", 2_048),
|
|
2096
|
+
original_width: integer(input.width, "TikTok transcode original_width", 1, 65_535),
|
|
2097
|
+
original_height: integer(input.height, "TikTok transcode original_height", 1, 65_535),
|
|
2098
|
+
original_duration_ms: Math.ceil(input.durationSeconds * 1_000),
|
|
2099
|
+
});
|
|
2100
|
+
return Object.freeze({
|
|
2101
|
+
body: Object.freeze({
|
|
2102
|
+
scene: 0,
|
|
2103
|
+
video_info: Object.freeze([videoInfo]),
|
|
2104
|
+
}),
|
|
2105
|
+
method: "POST",
|
|
2106
|
+
path: "/api/v1/video/transcode/result/",
|
|
2107
|
+
query: tikTokStudioBaseQuery(input.publicRegion),
|
|
2108
|
+
runtimeSecurity: TIKTOK_UNLISTED_STUDIO_RUNTIME_SECURITY,
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/** Classify only the public bundle's exact transcode status enum. */
|
|
2113
|
+
export function resolveTikTokVideoTranscodeState(value: unknown): TikTokVideoTranscodeState {
|
|
2114
|
+
return TIKTOK_VIDEO_TRANSCODE_STATES[
|
|
2115
|
+
integer(value, "TikTok transcode status", 0, 4)
|
|
2116
|
+
]!;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
export type TikTokVideoProjectPlan = Readonly<{
|
|
2120
|
+
allowAiRemix: boolean;
|
|
2121
|
+
allowComments: boolean;
|
|
2122
|
+
allowContentReuse: boolean;
|
|
2123
|
+
allowDuet: boolean;
|
|
2124
|
+
allowStitch: boolean;
|
|
2125
|
+
audience: "public" | "friends" | "private";
|
|
2126
|
+
caption: string | null;
|
|
2127
|
+
commercialContent: "none";
|
|
2128
|
+
containsSyntheticMedia: boolean;
|
|
2129
|
+
}>;
|
|
2130
|
+
|
|
2131
|
+
export type TikTokVideoProjectBinding = Readonly<{
|
|
2132
|
+
creationId: string;
|
|
2133
|
+
durationSeconds: number;
|
|
2134
|
+
enterPostPageFrom: string;
|
|
2135
|
+
posterDelay: number;
|
|
2136
|
+
soundExemption: 0 | 1;
|
|
2137
|
+
videoId: string;
|
|
2138
|
+
}>;
|
|
2139
|
+
|
|
2140
|
+
export const TIKTOK_VIDEO_VISIBILITY_TYPES = Object.freeze({
|
|
2141
|
+
public: 0 as const,
|
|
2142
|
+
private: 1 as const,
|
|
2143
|
+
friends: 2 as const,
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
/**
|
|
2147
|
+
* Build only the bundle-proven plain-video payload projection. The caller may
|
|
2148
|
+
* not dispatch this payload alone: its in-origin interception requirements,
|
|
2149
|
+
* authenticated response envelope, and independent readback remain separate
|
|
2150
|
+
* trust boundaries.
|
|
2151
|
+
*/
|
|
2152
|
+
export function buildTikTokVideoProjectPayloadProjection(
|
|
2153
|
+
plan: TikTokVideoProjectPlan,
|
|
2154
|
+
binding: TikTokVideoProjectBinding,
|
|
2155
|
+
): Readonly<Record<string, unknown>> {
|
|
2156
|
+
if (!isRecord(plan)) throw new Error("TikTok video project plan must be an object");
|
|
2157
|
+
exactObjectKeys(plan as JsonRecord, [
|
|
2158
|
+
"allowAiRemix",
|
|
2159
|
+
"allowComments",
|
|
2160
|
+
"allowContentReuse",
|
|
2161
|
+
"allowDuet",
|
|
2162
|
+
"allowStitch",
|
|
2163
|
+
"audience",
|
|
2164
|
+
"caption",
|
|
2165
|
+
"commercialContent",
|
|
2166
|
+
"containsSyntheticMedia",
|
|
2167
|
+
], [], "TikTok video project plan");
|
|
2168
|
+
for (const [field, value] of [
|
|
2169
|
+
["allowAiRemix", plan.allowAiRemix],
|
|
2170
|
+
["allowComments", plan.allowComments],
|
|
2171
|
+
["allowContentReuse", plan.allowContentReuse],
|
|
2172
|
+
["allowDuet", plan.allowDuet],
|
|
2173
|
+
["allowStitch", plan.allowStitch],
|
|
2174
|
+
["containsSyntheticMedia", plan.containsSyntheticMedia],
|
|
2175
|
+
] as const) boolean(value, `TikTok video project plan.${field}`);
|
|
2176
|
+
if (plan.audience !== "public" && plan.audience !== "friends" && plan.audience !== "private") {
|
|
2177
|
+
throw new Error("TikTok video project audience must be public, friends, or private");
|
|
2178
|
+
}
|
|
2179
|
+
if (plan.commercialContent !== "none") {
|
|
2180
|
+
throw new Error("TikTok video project currently supports only an explicit no-commercial-content declaration");
|
|
2181
|
+
}
|
|
2182
|
+
const caption = plan.caption === null
|
|
2183
|
+
? ""
|
|
2184
|
+
: boundedText(plan.caption, "TikTok video project caption", 500);
|
|
2185
|
+
if (!isRecord(binding)) throw new Error("TikTok video project binding must be an object");
|
|
2186
|
+
exactObjectKeys(binding as JsonRecord, [
|
|
2187
|
+
"creationId",
|
|
2188
|
+
"durationSeconds",
|
|
2189
|
+
"enterPostPageFrom",
|
|
2190
|
+
"posterDelay",
|
|
2191
|
+
"soundExemption",
|
|
2192
|
+
"videoId",
|
|
2193
|
+
], [], "TikTok video project binding");
|
|
2194
|
+
const durationSeconds = integer(
|
|
2195
|
+
binding.durationSeconds,
|
|
2196
|
+
"TikTok video project durationSeconds",
|
|
2197
|
+
0,
|
|
2198
|
+
86_400,
|
|
2199
|
+
);
|
|
2200
|
+
const visibilityType = TIKTOK_VIDEO_VISIBILITY_TYPES[plan.audience];
|
|
2201
|
+
const numeric = (value: boolean): 0 | 1 => value ? 1 : 0;
|
|
2202
|
+
return Object.freeze({
|
|
2203
|
+
post_common_info: Object.freeze({
|
|
2204
|
+
creation_id: bundleId(binding.creationId, "TikTok video project creationId", 512),
|
|
2205
|
+
enter_post_page_from: bundleId(
|
|
2206
|
+
binding.enterPostPageFrom,
|
|
2207
|
+
"TikTok video project enterPostPageFrom",
|
|
2208
|
+
128,
|
|
2209
|
+
),
|
|
2210
|
+
post_type: 3,
|
|
2211
|
+
}),
|
|
2212
|
+
feature_common_info_list: Object.freeze([Object.freeze({
|
|
2213
|
+
geofencing_regions: Object.freeze([]),
|
|
2214
|
+
tcm_params: '{"commerce_toggle_info":{}}',
|
|
2215
|
+
sound_exemption: integer(binding.soundExemption, "TikTok video project soundExemption", 0, 1),
|
|
2216
|
+
aigc_info: Object.freeze({
|
|
2217
|
+
aigc_label_type: plan.containsSyntheticMedia ? 1 : 0,
|
|
2218
|
+
}),
|
|
2219
|
+
privacy_setting_info: Object.freeze({
|
|
2220
|
+
visibility_type: visibilityType,
|
|
2221
|
+
allow_comment: numeric(plan.allowComments),
|
|
2222
|
+
allow_duet: numeric(plan.allowDuet),
|
|
2223
|
+
allow_stitch: numeric(plan.allowStitch),
|
|
2224
|
+
allow_content_reuse: numeric(plan.allowContentReuse),
|
|
2225
|
+
allow_ai_remix: plan.allowAiRemix ? 1 : 2,
|
|
2226
|
+
}),
|
|
2227
|
+
})]),
|
|
2228
|
+
single_post_req_list: Object.freeze([Object.freeze({
|
|
2229
|
+
batch_index: 0,
|
|
2230
|
+
video_id: bundleId(binding.videoId, "TikTok video project videoId", 2_048),
|
|
2231
|
+
is_long_video: numeric(durationSeconds > 60),
|
|
2232
|
+
single_post_feature_info: Object.freeze({
|
|
2233
|
+
text: caption,
|
|
2234
|
+
text_extra: Object.freeze([]),
|
|
2235
|
+
markup_text: caption,
|
|
2236
|
+
poster_delay: integer(binding.posterDelay, "TikTok video project posterDelay", 0, 86_400_000),
|
|
2237
|
+
}),
|
|
2238
|
+
})]),
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
export function buildTikTokProjectStatusRequestProjection(
|
|
2243
|
+
projectIdValue: unknown,
|
|
2244
|
+
publicRegionValue: unknown,
|
|
2245
|
+
): TikTokStudioRequestProjection {
|
|
2246
|
+
return Object.freeze({
|
|
2247
|
+
method: "GET",
|
|
2248
|
+
path: "/tiktok/web/project/status/v1/",
|
|
2249
|
+
query: Object.freeze({
|
|
2250
|
+
project_id: bundleId(projectIdValue, "TikTok project status project_id", 512),
|
|
2251
|
+
...tikTokStudioBaseQuery(publicRegionValue),
|
|
2252
|
+
}),
|
|
2253
|
+
runtimeSecurity: TIKTOK_UNLISTED_STUDIO_RUNTIME_SECURITY,
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
export const TIKTOK_PROJECT_STATUS_POLL_POLICY = Object.freeze({
|
|
2258
|
+
defaultDelayMs: 10_000,
|
|
2259
|
+
maxPostingObservations: 50,
|
|
2260
|
+
plainVideoInitialDelaysMs: Object.freeze([0, 1_000, 1_000, 1_000, 1_000]),
|
|
2261
|
+
videoEditedInitialDelaysMs: Object.freeze([10_000, 5_000, 5_000, 5_000, 5_000]),
|
|
2262
|
+
});
|
|
2263
|
+
|
|
2264
|
+
export function buildTikTokProjectPublishRequestProjection(
|
|
2265
|
+
plan: TikTokVideoProjectPlan,
|
|
2266
|
+
binding: TikTokVideoProjectBinding,
|
|
2267
|
+
runtime: Readonly<{
|
|
2268
|
+
publicRegion: unknown;
|
|
2269
|
+
resolvedTimeZone: unknown;
|
|
2270
|
+
}>,
|
|
2271
|
+
): TikTokStudioRequestProjection {
|
|
2272
|
+
if (!isRecord(runtime)) throw new Error("TikTok project publish runtime must be an object");
|
|
2273
|
+
exactObjectKeys(
|
|
2274
|
+
runtime as JsonRecord,
|
|
2275
|
+
["publicRegion", "resolvedTimeZone"],
|
|
2276
|
+
[],
|
|
2277
|
+
"TikTok project publish runtime",
|
|
2278
|
+
);
|
|
2279
|
+
return Object.freeze({
|
|
2280
|
+
body: buildTikTokVideoProjectPayloadProjection(plan, binding),
|
|
2281
|
+
method: "POST",
|
|
2282
|
+
path: "/tiktok/web/project/post/v1/",
|
|
2283
|
+
query: Object.freeze({
|
|
2284
|
+
app_name: "tiktok_web",
|
|
2285
|
+
channel: "tiktok_web",
|
|
2286
|
+
device_platform: "web",
|
|
2287
|
+
tz_name: tikTokRuntimeIanaTimeZone(runtime.resolvedTimeZone),
|
|
2288
|
+
...tikTokStudioBaseQuery(runtime.publicRegion),
|
|
2289
|
+
}),
|
|
2290
|
+
runtimeSecurity: Object.freeze({
|
|
2291
|
+
acrawler: "required",
|
|
2292
|
+
antiCsrf: "not-listed-for-route",
|
|
2293
|
+
credentials: "include",
|
|
2294
|
+
csrfHeader: "not-explicit-for-route",
|
|
2295
|
+
execution: "authenticated-in-origin-studio-session",
|
|
2296
|
+
verifyFp: "not-requested-by-base-query",
|
|
2297
|
+
zti: "ab-gated",
|
|
2298
|
+
}),
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
export type TikTokProjectPublishProjection = Readonly<{
|
|
2303
|
+
batchIndex: 0;
|
|
2304
|
+
postId: string;
|
|
2305
|
+
projectId: string;
|
|
2306
|
+
}>;
|
|
2307
|
+
|
|
2308
|
+
/** Parse only the exact secret-free project-publish result projection. */
|
|
2309
|
+
export function parseTikTokProjectPublishProjection(
|
|
2310
|
+
value: unknown,
|
|
2311
|
+
): TikTokProjectPublishProjection {
|
|
2312
|
+
const root = record(value, "TikTok project publish projection");
|
|
2313
|
+
exactObjectKeys(
|
|
2314
|
+
root,
|
|
2315
|
+
["project_id", "single_post_resp_list", "status_code"],
|
|
2316
|
+
[],
|
|
2317
|
+
"TikTok project publish projection",
|
|
2318
|
+
);
|
|
2319
|
+
if (root.status_code !== 0) {
|
|
2320
|
+
throw new Error("TikTok project publish projection did not report status_code 0");
|
|
2321
|
+
}
|
|
2322
|
+
if (!Array.isArray(root.single_post_resp_list) || root.single_post_resp_list.length !== 1) {
|
|
2323
|
+
throw new Error("TikTok project publish projection must contain one exact post result");
|
|
2324
|
+
}
|
|
2325
|
+
const item = record(
|
|
2326
|
+
root.single_post_resp_list[0],
|
|
2327
|
+
"TikTok project publish projection.single_post_resp_list[0]",
|
|
2328
|
+
);
|
|
2329
|
+
exactObjectKeys(
|
|
2330
|
+
item,
|
|
2331
|
+
["batch_index", "item_id", "status_code"],
|
|
2332
|
+
[],
|
|
2333
|
+
"TikTok project publish projection.single_post_resp_list[0]",
|
|
2334
|
+
);
|
|
2335
|
+
if (item.batch_index !== 0 || item.status_code !== 0) {
|
|
2336
|
+
throw new Error("TikTok project publish post result did not bind batch 0 success");
|
|
2337
|
+
}
|
|
2338
|
+
return Object.freeze({
|
|
2339
|
+
batchIndex: 0,
|
|
2340
|
+
postId: decimalId(item.item_id, "TikTok project publish item_id"),
|
|
2341
|
+
projectId: bundleId(root.project_id, "TikTok project publish project_id", 512),
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
export type TikTokProjectState =
|
|
2346
|
+
| "unknown"
|
|
2347
|
+
| "posting"
|
|
2348
|
+
| "success"
|
|
2349
|
+
| "failed"
|
|
2350
|
+
| "vediting";
|
|
2351
|
+
|
|
2352
|
+
export type TikTokProjectTaskState = "unknown" | "posting" | "success" | "failed";
|
|
2353
|
+
|
|
2354
|
+
export type TikTokProjectStatusProjection = Readonly<{
|
|
2355
|
+
state: TikTokProjectState;
|
|
2356
|
+
tasks: readonly Readonly<{
|
|
2357
|
+
postId: string | null;
|
|
2358
|
+
state: TikTokProjectTaskState;
|
|
2359
|
+
}>[];
|
|
2360
|
+
}>;
|
|
2361
|
+
|
|
2362
|
+
const TIKTOK_PROJECT_STATES = Object.freeze([
|
|
2363
|
+
"unknown",
|
|
2364
|
+
"posting",
|
|
2365
|
+
"success",
|
|
2366
|
+
"failed",
|
|
2367
|
+
"vediting",
|
|
2368
|
+
] as const satisfies readonly TikTokProjectState[]);
|
|
2369
|
+
|
|
2370
|
+
const TIKTOK_PROJECT_TASK_STATES = Object.freeze([
|
|
2371
|
+
"unknown",
|
|
2372
|
+
"posting",
|
|
2373
|
+
"success",
|
|
2374
|
+
"failed",
|
|
2375
|
+
] as const satisfies readonly TikTokProjectTaskState[]);
|
|
2376
|
+
|
|
2377
|
+
/**
|
|
2378
|
+
* Parse the bundle-retained status projection without converting a missing
|
|
2379
|
+
* task item ID into post absence or completion evidence.
|
|
2380
|
+
*/
|
|
2381
|
+
export function parseTikTokProjectStatusProjection(
|
|
2382
|
+
value: unknown,
|
|
2383
|
+
expectedPostIdValue?: unknown,
|
|
2384
|
+
): TikTokProjectStatusProjection {
|
|
2385
|
+
const root = record(value, "TikTok project status projection");
|
|
2386
|
+
exactObjectKeys(
|
|
2387
|
+
root,
|
|
2388
|
+
["project_status", "task_list"],
|
|
2389
|
+
[],
|
|
2390
|
+
"TikTok project status projection",
|
|
2391
|
+
);
|
|
2392
|
+
const projectStatus = integer(
|
|
2393
|
+
root.project_status,
|
|
2394
|
+
"TikTok project status projection.project_status",
|
|
2395
|
+
0,
|
|
2396
|
+
4,
|
|
2397
|
+
);
|
|
2398
|
+
if (!Array.isArray(root.task_list) || root.task_list.length < 1 || root.task_list.length > 16) {
|
|
2399
|
+
throw new Error("TikTok project status projection must contain bounded tasks");
|
|
2400
|
+
}
|
|
2401
|
+
const expectedPostId = expectedPostIdValue === undefined
|
|
2402
|
+
? null
|
|
2403
|
+
: decimalId(expectedPostIdValue, "expected TikTok project-status post ID");
|
|
2404
|
+
const tasks = root.task_list.map((rawTask, index) => {
|
|
2405
|
+
const task = record(rawTask, `TikTok project status task ${index + 1}`);
|
|
2406
|
+
exactObjectKeys(
|
|
2407
|
+
task,
|
|
2408
|
+
["task_status"],
|
|
2409
|
+
["item_id"],
|
|
2410
|
+
`TikTok project status task ${index + 1}`,
|
|
2411
|
+
);
|
|
2412
|
+
const taskStatus = integer(
|
|
2413
|
+
task.task_status,
|
|
2414
|
+
`TikTok project status task ${index + 1}.task_status`,
|
|
2415
|
+
0,
|
|
2416
|
+
3,
|
|
2417
|
+
);
|
|
2418
|
+
const postId = task.item_id === undefined || task.item_id === null
|
|
2419
|
+
? null
|
|
2420
|
+
: decimalId(task.item_id, `TikTok project status task ${index + 1}.item_id`);
|
|
2421
|
+
if (expectedPostId !== null && postId !== null && postId !== expectedPostId) {
|
|
2422
|
+
throw new Error("TikTok project status switched the accepted post ID");
|
|
2423
|
+
}
|
|
2424
|
+
return Object.freeze({
|
|
2425
|
+
postId,
|
|
2426
|
+
state: TIKTOK_PROJECT_TASK_STATES[taskStatus]!,
|
|
2427
|
+
});
|
|
2428
|
+
});
|
|
2429
|
+
return Object.freeze({
|
|
2430
|
+
state: TIKTOK_PROJECT_STATES[projectStatus]!,
|
|
2431
|
+
tasks: Object.freeze(tasks),
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
/**
|
|
2436
|
+
* Build the exact read-only Studio detail request. The time zone is runtime
|
|
2437
|
+
* context derived by the executor, never a semantic-operation input.
|
|
2438
|
+
*/
|
|
2439
|
+
export function buildTikTokPostDetailRequestProjection(
|
|
2440
|
+
postIdValue: unknown,
|
|
2441
|
+
resolvedTimeZoneValue: unknown,
|
|
2442
|
+
): TikTokBundleRequestProjection {
|
|
2443
|
+
const resolvedTimeZone = tikTokRuntimeIanaTimeZone(resolvedTimeZoneValue);
|
|
2444
|
+
return Object.freeze({
|
|
2445
|
+
method: "GET",
|
|
2446
|
+
path: "/api/v1/post/detail/",
|
|
2447
|
+
query: Object.freeze({
|
|
2448
|
+
tz_name: resolvedTimeZone,
|
|
2449
|
+
item_id: decimalId(postIdValue, "TikTok detail post ID"),
|
|
2450
|
+
aid: "1988",
|
|
2451
|
+
}),
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
function tikTokRuntimeIanaTimeZone(resolvedTimeZoneValue: unknown): string {
|
|
2456
|
+
const resolvedTimeZone = requiredString(
|
|
2457
|
+
resolvedTimeZoneValue,
|
|
2458
|
+
"TikTok runtime IANA time zone",
|
|
2459
|
+
128,
|
|
2460
|
+
);
|
|
2461
|
+
if (!/^[A-Za-z][A-Za-z0-9._+-]*(?:\/[A-Za-z0-9][A-Za-z0-9._+-]*)*$/u.test(resolvedTimeZone)) {
|
|
2462
|
+
throw new Error("TikTok runtime time zone must be a bounded IANA name");
|
|
2463
|
+
}
|
|
2464
|
+
try {
|
|
2465
|
+
new Intl.DateTimeFormat("en-US", { timeZone: resolvedTimeZone }).format(0);
|
|
2466
|
+
} catch {
|
|
2467
|
+
throw new Error("TikTok runtime time zone must be a recognized IANA name");
|
|
2468
|
+
}
|
|
2469
|
+
return resolvedTimeZone;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
const TIKTOK_DELETE_PERMISSION_MAX_JSON_DEPTH = 32;
|
|
2473
|
+
const TIKTOK_DELETE_PERMISSION_MAX_JSON_NODES = 16_384;
|
|
2474
|
+
const TIKTOK_DELETE_PERMISSION_MAX_ARRAY_ITEMS = 64;
|
|
2475
|
+
const TIKTOK_DELETE_PERMISSION_MAX_JSON_BYTES = 1024 * 1024;
|
|
2476
|
+
const TIKTOK_DELETE_PERMISSION_MAX_KEY_BYTES = 1_024;
|
|
2477
|
+
const TIKTOK_DELETE_PERMISSION_MAX_STRING_BYTES = 256 * 1024;
|
|
2478
|
+
|
|
2479
|
+
type TikTokDeletePermissionJson =
|
|
2480
|
+
| null
|
|
2481
|
+
| boolean
|
|
2482
|
+
| number
|
|
2483
|
+
| string
|
|
2484
|
+
| readonly TikTokDeletePermissionJson[]
|
|
2485
|
+
| { readonly [key: string]: TikTokDeletePermissionJson };
|
|
2486
|
+
|
|
2487
|
+
type TikTokDeletePermissionJsonState = {
|
|
2488
|
+
readonly active: WeakSet<object>;
|
|
2489
|
+
bytes: number;
|
|
2490
|
+
nodes: number;
|
|
2491
|
+
};
|
|
2492
|
+
|
|
2493
|
+
function addTikTokDeletePermissionJsonBytes(
|
|
2494
|
+
state: TikTokDeletePermissionJsonState,
|
|
2495
|
+
bytes: number,
|
|
2496
|
+
): void {
|
|
2497
|
+
state.bytes += bytes;
|
|
2498
|
+
if (state.bytes > TIKTOK_DELETE_PERMISSION_MAX_JSON_BYTES) {
|
|
2499
|
+
throw new Error("TikTok delete permission projection exceeds its total JSON byte bound");
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
function snapshotTikTokDeletePermissionJson(
|
|
2504
|
+
value: unknown,
|
|
2505
|
+
label: string,
|
|
2506
|
+
): TikTokDeletePermissionJson {
|
|
2507
|
+
const state: TikTokDeletePermissionJsonState = {
|
|
2508
|
+
active: new WeakSet<object>(),
|
|
2509
|
+
bytes: 0,
|
|
2510
|
+
nodes: 0,
|
|
2511
|
+
};
|
|
2512
|
+
const visit = (candidate: unknown, depth: number): TikTokDeletePermissionJson => {
|
|
2513
|
+
state.nodes += 1;
|
|
2514
|
+
if (
|
|
2515
|
+
depth > TIKTOK_DELETE_PERMISSION_MAX_JSON_DEPTH
|
|
2516
|
+
|| state.nodes > TIKTOK_DELETE_PERMISSION_MAX_JSON_NODES
|
|
2517
|
+
) throw new Error(`${label} exceeds its JSON structural bound`);
|
|
2518
|
+
if (candidate === null) {
|
|
2519
|
+
addTikTokDeletePermissionJsonBytes(state, 4);
|
|
2520
|
+
return null;
|
|
2521
|
+
}
|
|
2522
|
+
if (typeof candidate === "boolean") {
|
|
2523
|
+
addTikTokDeletePermissionJsonBytes(state, candidate ? 4 : 5);
|
|
2524
|
+
return candidate;
|
|
2525
|
+
}
|
|
2526
|
+
if (typeof candidate === "string") {
|
|
2527
|
+
if (Buffer.byteLength(candidate, "utf8") > TIKTOK_DELETE_PERMISSION_MAX_STRING_BYTES) {
|
|
2528
|
+
throw new Error(`${label} contains an oversized string`);
|
|
2529
|
+
}
|
|
2530
|
+
addTikTokDeletePermissionJsonBytes(
|
|
2531
|
+
state,
|
|
2532
|
+
Buffer.byteLength(JSON.stringify(candidate), "utf8"),
|
|
2533
|
+
);
|
|
2534
|
+
return candidate;
|
|
2535
|
+
}
|
|
2536
|
+
if (typeof candidate === "number") {
|
|
2537
|
+
if (!Number.isFinite(candidate)) throw new Error(`${label} contains a non-finite number`);
|
|
2538
|
+
addTikTokDeletePermissionJsonBytes(
|
|
2539
|
+
state,
|
|
2540
|
+
Buffer.byteLength(JSON.stringify(candidate), "utf8"),
|
|
2541
|
+
);
|
|
2542
|
+
return candidate;
|
|
2543
|
+
}
|
|
2544
|
+
if (typeof candidate !== "object") {
|
|
2545
|
+
throw new Error(`${label} must contain only JSON data`);
|
|
2546
|
+
}
|
|
2547
|
+
if (nodeTypes.isProxy(candidate)) throw new Error(`${label} must not contain proxies`);
|
|
2548
|
+
if (state.active.has(candidate)) throw new Error(`${label} must not contain a cycle`);
|
|
2549
|
+
state.active.add(candidate);
|
|
2550
|
+
try {
|
|
2551
|
+
if (Array.isArray(candidate)) {
|
|
2552
|
+
if (Object.getPrototypeOf(candidate) !== Array.prototype) {
|
|
2553
|
+
throw new Error(`${label} arrays must use the standard prototype`);
|
|
2554
|
+
}
|
|
2555
|
+
const length = candidate.length;
|
|
2556
|
+
if (length > TIKTOK_DELETE_PERMISSION_MAX_ARRAY_ITEMS) {
|
|
2557
|
+
throw new Error(
|
|
2558
|
+
`${label} arrays must contain at most ${TIKTOK_DELETE_PERMISSION_MAX_ARRAY_ITEMS} items`,
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
if (state.nodes + length > TIKTOK_DELETE_PERMISSION_MAX_JSON_NODES) {
|
|
2562
|
+
throw new Error(`${label} exceeds its JSON structural bound`);
|
|
2563
|
+
}
|
|
2564
|
+
const ownKeys = Reflect.ownKeys(candidate);
|
|
2565
|
+
const expectedKeys = new Set<PropertyKey>([
|
|
2566
|
+
"length",
|
|
2567
|
+
...Array.from({ length }, (_unused, index) => String(index)),
|
|
2568
|
+
]);
|
|
2569
|
+
if (
|
|
2570
|
+
ownKeys.length !== expectedKeys.size
|
|
2571
|
+
|| ownKeys.some((key) => !expectedKeys.has(key))
|
|
2572
|
+
) throw new Error(`${label} arrays must be dense data arrays without named fields`);
|
|
2573
|
+
addTikTokDeletePermissionJsonBytes(state, 2 + Math.max(0, length - 1));
|
|
2574
|
+
const cloned: TikTokDeletePermissionJson[] = [];
|
|
2575
|
+
for (let index = 0; index < length; index += 1) {
|
|
2576
|
+
const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index));
|
|
2577
|
+
if (
|
|
2578
|
+
descriptor === undefined
|
|
2579
|
+
|| !descriptor.enumerable
|
|
2580
|
+
|| !("value" in descriptor)
|
|
2581
|
+
) throw new Error(`${label} arrays must contain only enumerable data elements`);
|
|
2582
|
+
cloned.push(visit(descriptor.value, depth + 1));
|
|
2583
|
+
}
|
|
2584
|
+
return Object.freeze(cloned);
|
|
2585
|
+
}
|
|
2586
|
+
const prototype: unknown = Object.getPrototypeOf(candidate);
|
|
2587
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
2588
|
+
throw new Error(`${label} objects must use a plain prototype`);
|
|
2589
|
+
}
|
|
2590
|
+
const ownKeys = Reflect.ownKeys(candidate);
|
|
2591
|
+
if (ownKeys.some((key) => typeof key !== "string")) {
|
|
2592
|
+
throw new Error(`${label} objects must not contain symbol fields`);
|
|
2593
|
+
}
|
|
2594
|
+
if (state.nodes + ownKeys.length > TIKTOK_DELETE_PERMISSION_MAX_JSON_NODES) {
|
|
2595
|
+
throw new Error(`${label} exceeds its JSON structural bound`);
|
|
2596
|
+
}
|
|
2597
|
+
const keys = (ownKeys as string[]).sort((left, right) => left.localeCompare(right));
|
|
2598
|
+
let objectBytes = 2 + Math.max(0, keys.length - 1) + keys.length;
|
|
2599
|
+
for (const key of keys) {
|
|
2600
|
+
if (Buffer.byteLength(key, "utf8") > TIKTOK_DELETE_PERMISSION_MAX_KEY_BYTES) {
|
|
2601
|
+
throw new Error(`${label} contains an oversized property name`);
|
|
2602
|
+
}
|
|
2603
|
+
objectBytes += Buffer.byteLength(JSON.stringify(key), "utf8");
|
|
2604
|
+
}
|
|
2605
|
+
addTikTokDeletePermissionJsonBytes(state, objectBytes);
|
|
2606
|
+
const cloned = Object.create(null) as Record<string, TikTokDeletePermissionJson>;
|
|
2607
|
+
for (const key of keys) {
|
|
2608
|
+
const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
|
|
2609
|
+
if (
|
|
2610
|
+
descriptor === undefined
|
|
2611
|
+
|| !descriptor.enumerable
|
|
2612
|
+
|| !("value" in descriptor)
|
|
2613
|
+
) throw new Error(`${label} objects must contain only enumerable data properties`);
|
|
2614
|
+
cloned[key] = visit(descriptor.value, depth + 1);
|
|
2615
|
+
}
|
|
2616
|
+
return Object.freeze(cloned);
|
|
2617
|
+
} finally {
|
|
2618
|
+
state.active.delete(candidate);
|
|
2619
|
+
}
|
|
2620
|
+
};
|
|
2621
|
+
return visit(value, 0);
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
/** Parse the exact permission array selected from edit_post_info. */
|
|
2625
|
+
export function parseTikTokDeletePermissionProjection(
|
|
2626
|
+
value: unknown,
|
|
2627
|
+
): Readonly<{ recyclable: boolean }> {
|
|
2628
|
+
const snapshot = snapshotTikTokDeletePermissionJson(
|
|
2629
|
+
value,
|
|
2630
|
+
"TikTok delete permission projection",
|
|
2631
|
+
);
|
|
2632
|
+
const projection = record(snapshot, "TikTok delete permission projection");
|
|
2633
|
+
exactObjectKeys(projection, ["biz_permissions"], [], "TikTok delete permission projection");
|
|
2634
|
+
if (
|
|
2635
|
+
!Array.isArray(projection.biz_permissions)
|
|
2636
|
+
|| projection.biz_permissions.length < 1
|
|
2637
|
+
|| projection.biz_permissions.length > 64
|
|
2638
|
+
) throw new Error("TikTok delete permission projection must contain bounded permissions");
|
|
2639
|
+
let recyclableOwners = 0;
|
|
2640
|
+
for (const [index, rawPermission] of projection.biz_permissions.entries()) {
|
|
2641
|
+
const permission = record(rawPermission, `TikTok delete permission ${index + 1}`);
|
|
2642
|
+
const label = `TikTok delete permission ${index + 1}`;
|
|
2643
|
+
exactObjectKeys(
|
|
2644
|
+
permission,
|
|
2645
|
+
["biz_reason", "biz_status", "biz_type"],
|
|
2646
|
+
["is_recyclable"],
|
|
2647
|
+
label,
|
|
2648
|
+
);
|
|
2649
|
+
integer(permission.biz_reason, `${label}.biz_reason`);
|
|
2650
|
+
integer(permission.biz_status, `${label}.biz_status`);
|
|
2651
|
+
integer(permission.biz_type, `${label}.biz_type`);
|
|
2652
|
+
if (!Object.hasOwn(permission, "is_recyclable")) continue;
|
|
2653
|
+
const isRecyclable = boolean(
|
|
2654
|
+
permission.is_recyclable,
|
|
2655
|
+
`${label}.is_recyclable`,
|
|
2656
|
+
);
|
|
2657
|
+
if (isRecyclable) {
|
|
2658
|
+
recyclableOwners += 1;
|
|
2659
|
+
if (recyclableOwners > 1) {
|
|
2660
|
+
throw new Error(
|
|
2661
|
+
"TikTok delete permission projection must contain at most one exact true is_recyclable permission",
|
|
2662
|
+
);
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
return Object.freeze({ recyclable: recyclableOwners === 1 });
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
/**
|
|
2670
|
+
* Parse only the permission branch retained from authored-post detail. This
|
|
2671
|
+
* projection does not prove the target actor, caption, presence, tombstone, or
|
|
2672
|
+
* absence; those bindings remain capture-required.
|
|
2673
|
+
*/
|
|
2674
|
+
export function parseTikTokPostDetailDeletePermissionProjection(
|
|
2675
|
+
value: unknown,
|
|
2676
|
+
): Readonly<{ recyclable: boolean }> {
|
|
2677
|
+
const snapshot = snapshotTikTokDeletePermissionJson(
|
|
2678
|
+
value,
|
|
2679
|
+
"TikTok post-detail permission projection",
|
|
2680
|
+
);
|
|
2681
|
+
const root = record(snapshot, "TikTok post-detail permission projection");
|
|
2682
|
+
exactObjectKeys(
|
|
2683
|
+
root,
|
|
2684
|
+
["edit_post_info"],
|
|
2685
|
+
[],
|
|
2686
|
+
"TikTok post-detail permission projection",
|
|
2687
|
+
);
|
|
2688
|
+
const editPostInfo = record(
|
|
2689
|
+
root.edit_post_info,
|
|
2690
|
+
"TikTok post-detail permission projection.edit_post_info",
|
|
2691
|
+
);
|
|
2692
|
+
exactObjectKeys(
|
|
2693
|
+
editPostInfo,
|
|
2694
|
+
["edit_post_permission"],
|
|
2695
|
+
[],
|
|
2696
|
+
"TikTok post-detail permission projection.edit_post_info",
|
|
2697
|
+
);
|
|
2698
|
+
const permission = record(
|
|
2699
|
+
editPostInfo.edit_post_permission,
|
|
2700
|
+
"TikTok post-detail permission projection.edit_post_permission",
|
|
2701
|
+
);
|
|
2702
|
+
exactObjectKeys(
|
|
2703
|
+
permission,
|
|
2704
|
+
["biz_permissions", "visibility_permission"],
|
|
2705
|
+
[],
|
|
2706
|
+
"TikTok post-detail permission projection.edit_post_permission",
|
|
2707
|
+
);
|
|
2708
|
+
const visibilityPermission = record(
|
|
2709
|
+
permission.visibility_permission,
|
|
2710
|
+
"TikTok post-detail permission projection.edit_post_permission.visibility_permission",
|
|
2711
|
+
);
|
|
2712
|
+
const visibilityFields = [
|
|
2713
|
+
"available_for_ads",
|
|
2714
|
+
"everyone",
|
|
2715
|
+
"followers",
|
|
2716
|
+
"friends",
|
|
2717
|
+
"only_you",
|
|
2718
|
+
"sub_only",
|
|
2719
|
+
] as const;
|
|
2720
|
+
exactObjectKeys(
|
|
2721
|
+
visibilityPermission,
|
|
2722
|
+
visibilityFields,
|
|
2723
|
+
[],
|
|
2724
|
+
"TikTok post-detail permission projection.edit_post_permission.visibility_permission",
|
|
2725
|
+
);
|
|
2726
|
+
for (const field of visibilityFields) {
|
|
2727
|
+
integer(
|
|
2728
|
+
visibilityPermission[field],
|
|
2729
|
+
`TikTok post-detail permission projection.edit_post_permission.visibility_permission.${field}`,
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
return parseTikTokDeletePermissionProjection({
|
|
2733
|
+
biz_permissions: permission.biz_permissions,
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
export function buildTikTokPublishedPostDeleteBody(input: {
|
|
2738
|
+
readonly postId: unknown;
|
|
2739
|
+
readonly projectId: unknown;
|
|
2740
|
+
readonly recyclable: unknown;
|
|
2741
|
+
}): Readonly<Record<string, unknown>> {
|
|
2742
|
+
if (input.recyclable !== true) {
|
|
2743
|
+
throw new Error("TikTok recycle-bin deletion requires exact is_recyclable true permission");
|
|
2744
|
+
}
|
|
2745
|
+
const projectId = input.projectId === undefined
|
|
2746
|
+
? undefined
|
|
2747
|
+
: bundleId(input.projectId, "TikTok delete project ID", 512);
|
|
2748
|
+
return Object.freeze({
|
|
2749
|
+
aweme_id: decimalId(input.postId, "TikTok delete post ID"),
|
|
2750
|
+
...(projectId === undefined ? {} : { project_id: projectId }),
|
|
2751
|
+
scene: 1,
|
|
2752
|
+
delete: Object.freeze({ delete_type: 1 }),
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
export function buildTikTokPublishedPostRecycleRequest(input: {
|
|
2757
|
+
readonly postId: unknown;
|
|
2758
|
+
readonly projectId: unknown;
|
|
2759
|
+
readonly recyclable: unknown;
|
|
2760
|
+
}): TikTokBundleRequestProjection {
|
|
2761
|
+
return Object.freeze({
|
|
2762
|
+
body: buildTikTokPublishedPostDeleteBody(input),
|
|
2763
|
+
method: "POST",
|
|
2764
|
+
path: "/tiktok/post/edit/v1/",
|
|
2765
|
+
query: Object.freeze({}),
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
/**
|
|
2770
|
+
* This is the required execution ordering for any future durable transport.
|
|
2771
|
+
* ApplyUploadInner may allocate provider state despite using GET, so durable
|
|
2772
|
+
* dispatch admission must precede it and every later mutation; conditional
|
|
2773
|
+
* processing and project-status polls stay ordered after their trigger.
|
|
2774
|
+
*/
|
|
2775
|
+
export const TIKTOK_VIDEO_DURABLE_DISPATCH_ORDER = Object.freeze([
|
|
2776
|
+
"beforeDispatch",
|
|
2777
|
+
"ApplyUploadInner",
|
|
2778
|
+
"TOS.init",
|
|
2779
|
+
"TOS.transfer",
|
|
2780
|
+
"TOS.finish",
|
|
2781
|
+
"CommitUploadInner",
|
|
2782
|
+
"transcode.enable-if-required",
|
|
2783
|
+
"transcode.result-until-terminal-if-required",
|
|
2784
|
+
"project.publish",
|
|
2785
|
+
"project.status-until-terminal",
|
|
2786
|
+
] as const);
|