@volter/twin-tiktok 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +310 -0
- package/client/tiktok-consent.tsx +154 -0
- package/client/tiktok-mirror.css +137 -0
- package/client/tiktok-mirror.tsx +492 -0
- package/dist/client/tiktok-consent.bundle.js +18 -0
- package/dist/client/tiktok-consent.d.ts +47 -0
- package/dist/client/tiktok-consent.js +20 -0
- package/dist/client/tiktok-consent.tsx +154 -0
- package/dist/client/tiktok-mirror.bundle.js +487 -0
- package/dist/client/tiktok-mirror.css +137 -0
- package/dist/client/tiktok-mirror.d.ts +42 -0
- package/dist/client/tiktok-mirror.js +315 -0
- package/dist/client/tiktok-mirror.tsx +492 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +44 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.js +167 -0
- package/dist/src/tiktok-blobs.d.ts +66 -0
- package/dist/src/tiktok-blobs.js +161 -0
- package/dist/src/tiktok-budget.d.ts +56 -0
- package/dist/src/tiktok-budget.js +136 -0
- package/dist/src/tiktok-capabilities.d.ts +7 -0
- package/dist/src/tiktok-capabilities.js +1855 -0
- package/dist/src/tiktok-conformance.d.ts +11 -0
- package/dist/src/tiktok-conformance.js +498 -0
- package/dist/src/tiktok-connector.d.ts +158 -0
- package/dist/src/tiktok-connector.js +600 -0
- package/dist/src/tiktok-consent-ui.d.ts +19 -0
- package/dist/src/tiktok-consent-ui.js +127 -0
- package/dist/src/tiktok-errors.d.ts +78 -0
- package/dist/src/tiktok-errors.js +175 -0
- package/dist/src/tiktok-ids.d.ts +16 -0
- package/dist/src/tiktok-ids.js +48 -0
- package/dist/src/tiktok-media.d.ts +7 -0
- package/dist/src/tiktok-media.js +86 -0
- package/dist/src/tiktok-mirror-ui.d.ts +49 -0
- package/dist/src/tiktok-mirror-ui.js +159 -0
- package/dist/src/tiktok-pkce.d.ts +25 -0
- package/dist/src/tiktok-pkce.js +56 -0
- package/dist/src/tiktok-posting.d.ts +100 -0
- package/dist/src/tiktok-posting.js +599 -0
- package/dist/src/tiktok-sample-mp4.d.ts +10 -0
- package/dist/src/tiktok-sample-mp4.js +55 -0
- package/dist/src/tiktok-scopes.d.ts +29 -0
- package/dist/src/tiktok-scopes.js +106 -0
- package/dist/src/tiktok-server.d.ts +28 -0
- package/dist/src/tiktok-server.js +89 -0
- package/dist/src/tiktok-store.d.ts +164 -0
- package/dist/src/tiktok-store.js +451 -0
- package/dist/src/tiktok-twin.d.ts +70 -0
- package/dist/src/tiktok-twin.js +1197 -0
- package/dist/src/tiktok-user.d.ts +28 -0
- package/dist/src/tiktok-user.js +174 -0
- package/package.json +74 -0
- package/src/cli.ts +43 -0
- package/src/index.ts +270 -0
- package/src/tiktok-blobs.ts +217 -0
- package/src/tiktok-budget.ts +163 -0
- package/src/tiktok-capabilities.ts +2022 -0
- package/src/tiktok-conformance.ts +526 -0
- package/src/tiktok-connector.ts +637 -0
- package/src/tiktok-consent-ui.ts +146 -0
- package/src/tiktok-errors.ts +197 -0
- package/src/tiktok-ids.ts +51 -0
- package/src/tiktok-journey.uitest.ts +305 -0
- package/src/tiktok-media.ts +89 -0
- package/src/tiktok-mirror-ui.ts +167 -0
- package/src/tiktok-pkce.ts +61 -0
- package/src/tiktok-posting.ts +617 -0
- package/src/tiktok-sample-mp4.ts +54 -0
- package/src/tiktok-scopes.ts +122 -0
- package/src/tiktok-server.ts +100 -0
- package/src/tiktok-store.ts +543 -0
- package/src/tiktok-twin.ts +1361 -0
- package/src/tiktok-user.ts +137 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A real video, as data only — the one sample file the pack's conformance probes, capability verifies
|
|
2
|
+
// and gate replay upload. Kept apart from the handler so the dev-plane gate can read it without
|
|
3
|
+
// importing the code it exists to sabotage.
|
|
4
|
+
/**
|
|
5
|
+
* A REAL video the posting probes upload: one second of 360x640 black at 24 FPS, H.264 in MP4
|
|
6
|
+
* (`ffmpeg -f lavfi -i color=c=black:size=360x640:rate=24 -t 1 -crf 51 -movflags +faststart`), the
|
|
7
|
+
* smallest file TikTok's checks accept — the twin reads its duration, picture size and frame rate,
|
|
8
|
+
* and a file it cannot read fails with file_format_check_failed.
|
|
9
|
+
*/
|
|
10
|
+
export const TINY_MP4 = Uint8Array.from(atob(''
|
|
11
|
+
+ 'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAQHbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAA'
|
|
12
|
+
+ 'AAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA'
|
|
13
|
+
+ 'AzF0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAA'
|
|
14
|
+
+ 'AAAAAAAAAAAAAABAAAAAAWgAAAKAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAEAAABAAAAAAKpbWRpYQAAACBtZGhk'
|
|
15
|
+
+ 'AAAAAAAAAAAAAAAAAAAwAAAAMABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAACVG1p'
|
|
16
|
+
+ 'bmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAhRzdGJsAAAAxHN0c2QA'
|
|
17
|
+
+ 'AAAAAAAAAQAAALRhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAWgCgABIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDIgbGli'
|
|
18
|
+
+ 'eDI2NAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBZAAf/+EAHGdkAB+scgRBcFHl8BEAAAMAAQAAAwAwDxgxhGABAAdo6EOBlLIs/fj4'
|
|
19
|
+
+ 'AAAAABBwYXNwAAAAAQAAAAEAAAAUYnRydAAAAAAAACVYAAAAAAAAABhzdHRzAAAAAAAAAAEAAAAYAAACAAAAABRzdHNzAAAAAAAA'
|
|
20
|
+
+ 'AAEAAAABAAAAeGN0dHMAAAAAAAAADQAAAAEAAAQAAAAAAQAAFAAAAAABAAAIAAAAAAMAAAAAAAAABAAAAgAAAAABAAAUAAAAAAEA'
|
|
21
|
+
+ 'AAgAAAAAAwAAAAAAAAAEAAACAAAAAAEAAAwAAAAAAQAABAAAAAABAAAAAAAAAAIAAAIAAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAAY'
|
|
22
|
+
+ 'AAAAAQAAAHRzdHN6AAAAAAAAAAAAAAAYAAADCQAAABIAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABcAAAASAAAA'
|
|
23
|
+
+ 'EgAAABIAAAASAAAAEgAAABIAAAASAAAAEgAAABkAAAASAAAAEgAAABIAAAASAAAAFHN0Y28AAAAAAAAAAQAABDcAAABidWR0YQAA'
|
|
24
|
+
+ 'AFptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAC1pbHN0AAAAJal0b28AAAAdZGF0YQAAAAEAAAAA'
|
|
25
|
+
+ 'TGF2ZjYyLjEyLjEwMgAAAAhmcmVlAAAEs21kYXQAAAKxBgX//63cRem95tlIt5Ys2CDZI+7veDI2NCAtIGNvcmUgMTY1IHIzMjIy'
|
|
26
|
+
+ 'IGIzNTYwNWEgLSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDI1IC0gaHR0cDovL3d3dy52aWRlb2xh'
|
|
27
|
+
+ 'bi5vcmcveDI2NC5odG1sIC0gb3B0aW9uczogY2FiYWM9MSByZWY9MTYgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDEzMyBt'
|
|
28
|
+
+ 'ZT11bWggc3VibWU9MTAgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0yNCBjaHJvbWFfbWU9MSB0'
|
|
29
|
+
+ 'cmVsbGlzPTIgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhy'
|
|
30
|
+
+ 'ZWFkcz0xOCBsb29rYWhlYWRfdGhyZWFkcz0zIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBi'
|
|
31
|
+
+ 'bHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTggYl9weXJhbWlkPTIgYl9hZGFwdD0yIGJfYmlhcz0w'
|
|
32
|
+
+ 'IGRpcmVjdD0zIHdlaWdodGI9MSBvcGVuX2dvcD0wIHdlaWdodHA9MiBrZXlpbnQ9MjUwIGtleWludF9taW49MjQgc2NlbmVjdXQ9'
|
|
33
|
+
+ 'NDAgaW50cmFfcmVmcmVzaD0wIHJjX2xvb2thaGVhZD02MCByYz1jcmYgbWJ0cmVlPTEgY3JmPTUxLjAgcWNvbXA9MC42MCBxcG1p'
|
|
34
|
+
+ 'bj0wIHFwbWF4PTY5IHFwc3RlcD00IGlwX3JhdGlvPTEuNDAgYXE9MToxLjAwAIAAAABQZYiBAAT/Hru//lSYAAADAAAKEgzs1poj'
|
|
35
|
+
+ 'pAAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAC7kAAAAOQZoJLYhP/wAAAwAAEnAA'
|
|
36
|
+
+ 'AAANQZ4QhxCPAAADAAA1IQAAAA0BnhgmiL8AAAMAADegAAAADQGeGEaIvwAAAwAAN6EAAAANAZ4YZoi/AAADAAA3oQAAAA0Bnhit'
|
|
37
|
+
+ 'SL8AAAMAADehAAAADQGeGM1IvwAAAwAAN6EAAAANAZ4Y7Ui/AAADAAA3oAAAAA0BnhkNSL8AAAMAADegAAAAE0GaGkk1AgLRMpgQ'
|
|
38
|
+
+ 'jwAAAwAAFTEAAAAOQZ4hpcQj/wAAAwAANSAAAAAOAZ4pRaIv/wAAAwAAN6AAAAAOAZ4pZaIv/wAAAwAAN6EAAAAOAZ4phaIv/wAA'
|
|
39
|
+
+ 'AwAAN6EAAAAOAZ4pzJIv/wAAAwAAN6EAAAAOAZ4p7JIv/wAAAwAAN6AAAAAOAZ4qDJIv/wAAAwAAN6AAAAAOAZ4qLJIv/wAAAwAA'
|
|
40
|
+
+ 'N6EAAAAVQZoq6bUCAtrRMpgBF/8AAAMAAB6QAAAADkGeMoSxH/8AAAMAADZgAAAADgGeOmSoi/8AAAMAADehAAAADgGeOqzSL/8A'
|
|
41
|
+
+ 'AAMAADegAAAADgGeOszSL/8AAAMAADeh'), (c) => c.charCodeAt(0));
|
|
42
|
+
/** TINY_MP4 grown to exactly `size` bytes by a trailing `free` box (ISO BMFF's padding box): still a
|
|
43
|
+
* valid, readable MP4, for the probes that need a video past TikTok's 5 MB chunk floor. */
|
|
44
|
+
export function paddedMp4(size) {
|
|
45
|
+
const pad = size - TINY_MP4.length;
|
|
46
|
+
if (pad < 8)
|
|
47
|
+
throw new Error(`paddedMp4: ${size} is below ${TINY_MP4.length + 8}`);
|
|
48
|
+
const out = new Uint8Array(size);
|
|
49
|
+
out.set(TINY_MP4);
|
|
50
|
+
new DataView(out.buffer).setUint32(TINY_MP4.length, pad);
|
|
51
|
+
out.set([0x66, 0x72, 0x65, 0x65], TINY_MP4.length + 4);
|
|
52
|
+
for (let i = TINY_MP4.length + 8; i < size; i += 1)
|
|
53
|
+
out[i] = (i * 31 + (i >> 11)) & 0xff;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type ScopeProduct = 'user' | 'video' | 'portability' | 'research' | 'local';
|
|
2
|
+
export type ScopeInfo = {
|
|
3
|
+
scope: string;
|
|
4
|
+
/** The vendor's own published description of the scope. */
|
|
5
|
+
label: string;
|
|
6
|
+
product: ScopeProduct;
|
|
7
|
+
/** In the vendor catalog? An unknown scope is still displayed, flagged, so a typo is visible. */
|
|
8
|
+
known: boolean;
|
|
9
|
+
};
|
|
10
|
+
/** The scopes TikTok's scopes-overview page publishes, keyed by scope id. */
|
|
11
|
+
export declare const SCOPE_CATALOG: Record<string, {
|
|
12
|
+
label: string;
|
|
13
|
+
product: ScopeProduct;
|
|
14
|
+
}>;
|
|
15
|
+
export declare const KNOWN_SCOPES: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* Comma-separated scope param -> ordered unique scope list.
|
|
18
|
+
*
|
|
19
|
+
* Whitespace around a comma is tolerated because a caller that pretty-prints its scope constant
|
|
20
|
+
* would otherwise send a scope literally named " video.list". The SEPARATOR the twin requires is
|
|
21
|
+
* still the comma: a space-separated X-style string parses as ONE unknown scope and renders
|
|
22
|
+
* flagged on the screen, which is the integration bug a developer needs to see.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseScopeParam(raw: string | null): string[];
|
|
25
|
+
/** The wire form: comma-separated, no spaces (the docs' own `user.info.basic,video.list`). */
|
|
26
|
+
export declare function formatScopeParam(scopes: readonly string[]): string;
|
|
27
|
+
export declare function describeScope(scope: string): ScopeInfo;
|
|
28
|
+
export declare function describeScopes(scopes: readonly string[]): ScopeInfo[];
|
|
29
|
+
export declare function sortScopesForConsent(scopes: readonly string[]): string[];
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// TikTok OAuth scopes — the vendor's own catalog, transcribed from the TikTok for Developers
|
|
2
|
+
// "Scopes Overview" page (https://developers.tiktok.com/doc/tiktok-api-scopes/, fetched
|
|
3
|
+
// 2026-09-13) together with the per-product reference pages the overview links to.
|
|
4
|
+
//
|
|
5
|
+
// TWO THINGS THIS VENDOR DOES THAT THE X / GOOGLE CONSENT MODELS DO NOT:
|
|
6
|
+
//
|
|
7
|
+
// 1. The scope parameter is COMMA-separated (`scope=user.info.basic,user.info.profile`), not
|
|
8
|
+
// space-separated. That is documented on the Login Kit for Web parameter table ("A comma (,)
|
|
9
|
+
// separated string of authorization scope(s)") and it is exactly how Dub builds its authorize
|
|
10
|
+
// URL. A twin that accepted only spaces would refuse the vendor's own callers.
|
|
11
|
+
//
|
|
12
|
+
// 2. Consent is GRANULAR, not all-or-nothing. The documented callback carries a `scopes`
|
|
13
|
+
// parameter — "A comma-separated (,) string of authorization scope(s), which the user has
|
|
14
|
+
// granted" — which only has meaning if the granted set can be a SUBSET of the requested set,
|
|
15
|
+
// and the token response's `scope` is likewise "the scopes the user has agreed to authorize".
|
|
16
|
+
// So the authorize screen this twin serves offers one checkbox per requested scope and the
|
|
17
|
+
// granted set is what came back checked. EVIDENCE BOUNDARY: the two parameters above are
|
|
18
|
+
// documented; the SHAPE of the live affordance (per-scope toggles, which scopes are
|
|
19
|
+
// mandatory, what the buttons say) was not captured from a vendor artefact and is pinned by
|
|
20
|
+
// `tiktok.consent.optional_scope_toggles` and `tiktok.consent.button_wording` (todo).
|
|
21
|
+
//
|
|
22
|
+
// EVIDENCE BOUNDARY on the LABELS, and it is NOT uniform — say which is which rather than let a
|
|
23
|
+
// "verbatim" claim cover the lot. THREE labels are the Scopes Overview's own published text, word
|
|
24
|
+
// for word, and are asserted as such by `tiktok.scopes.catalog_matches_vendor_list`:
|
|
25
|
+
// `user.info.basic`, `user.info.profile` and `video.list`. The other EIGHTEEN are TWIN PARAPHRASES
|
|
26
|
+
// written from the overview's per-product descriptions — accurate in substance, not quotations —
|
|
27
|
+
// because the overview publishes those rows as product prose rather than as a scope string. Every
|
|
28
|
+
// label is developer-facing either way, never a capture of the live consent sheet, so
|
|
29
|
+
// `tiktok.scopes.consent_wording` (todo) pins the strings a person actually reads. The SCOPE IDS,
|
|
30
|
+
// by contrast, are all twenty-one exactly as published, and that set is what the capability
|
|
31
|
+
// asserts a bijection against.
|
|
32
|
+
/** The scopes TikTok's scopes-overview page publishes, keyed by scope id. */
|
|
33
|
+
export const SCOPE_CATALOG = {
|
|
34
|
+
// ── User Info API ──
|
|
35
|
+
// VERBATIM marks a label that is the Scopes Overview's own published text, word for word;
|
|
36
|
+
// every unmarked label is a TWIN PARAPHRASE of that page's product prose (see the header).
|
|
37
|
+
// VERBATIM
|
|
38
|
+
'user.info.basic': { label: "Read a user's profile info (open id, avatar, display name...)", product: 'user' },
|
|
39
|
+
// VERBATIM
|
|
40
|
+
'user.info.profile': { label: 'Read access to profile_web_link, profile_deep_link, bio_description, is_verified', product: 'user' },
|
|
41
|
+
'user.info.stats': { label: 'Read your engagement metrics: follower count, following count, likes count and video count', product: 'user' },
|
|
42
|
+
// ── Display API / Content Posting API ──
|
|
43
|
+
// VERBATIM
|
|
44
|
+
'video.list': { label: "Read a user's public videos on TikTok", product: 'video' },
|
|
45
|
+
'video.upload': { label: 'Share videos to your account as drafts, for you to edit before posting', product: 'video' },
|
|
46
|
+
'video.publish': { label: "Directly post content to a user's TikTok profile", product: 'video' },
|
|
47
|
+
// ── Data Portability API ──
|
|
48
|
+
'portability.activity.ongoing': { label: 'Export your activity data on an ongoing basis', product: 'portability' },
|
|
49
|
+
'portability.activity.single': { label: 'Export your activity data once', product: 'portability' },
|
|
50
|
+
'portability.all.ongoing': { label: 'Export your complete data archive on an ongoing basis', product: 'portability' },
|
|
51
|
+
'portability.all.single': { label: 'Export your complete data archive once', product: 'portability' },
|
|
52
|
+
'portability.directmessages.ongoing': { label: 'Export your direct messages on an ongoing basis', product: 'portability' },
|
|
53
|
+
'portability.directmessages.single': { label: 'Export your direct messages once', product: 'portability' },
|
|
54
|
+
'portability.postsandprofile.ongoing': { label: 'Export your posts and profile data on an ongoing basis', product: 'portability' },
|
|
55
|
+
'portability.postsandprofile.single': { label: 'Export your posts and profile data once', product: 'portability' },
|
|
56
|
+
// ── Research API ──
|
|
57
|
+
'research.data.basic': { label: 'Access public data for research purposes', product: 'research' },
|
|
58
|
+
'research.data.u18eu': { label: 'Access data of European users under 18 for research purposes', product: 'research' },
|
|
59
|
+
'research.data.vra': { label: 'Access vetted-researcher provisioned data', product: 'research' },
|
|
60
|
+
'research.adlib.basic': { label: 'Access commercial content data for research purposes', product: 'research' },
|
|
61
|
+
// ── Local Services API ──
|
|
62
|
+
'local.product.manage': { label: 'Manage the products of your local services account', product: 'local' },
|
|
63
|
+
'local.shop.manage': { label: 'Manage the shops of your local services account', product: 'local' },
|
|
64
|
+
'local.voucher.manage': { label: 'Manage the vouchers of your local services account', product: 'local' },
|
|
65
|
+
};
|
|
66
|
+
export const KNOWN_SCOPES = Object.keys(SCOPE_CATALOG);
|
|
67
|
+
/**
|
|
68
|
+
* Comma-separated scope param -> ordered unique scope list.
|
|
69
|
+
*
|
|
70
|
+
* Whitespace around a comma is tolerated because a caller that pretty-prints its scope constant
|
|
71
|
+
* would otherwise send a scope literally named " video.list". The SEPARATOR the twin requires is
|
|
72
|
+
* still the comma: a space-separated X-style string parses as ONE unknown scope and renders
|
|
73
|
+
* flagged on the screen, which is the integration bug a developer needs to see.
|
|
74
|
+
*/
|
|
75
|
+
export function parseScopeParam(raw) {
|
|
76
|
+
if (!raw)
|
|
77
|
+
return [];
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const piece of raw.split(',')) {
|
|
81
|
+
const s = piece.trim();
|
|
82
|
+
if (!s || seen.has(s))
|
|
83
|
+
continue;
|
|
84
|
+
seen.add(s);
|
|
85
|
+
out.push(s);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/** The wire form: comma-separated, no spaces (the docs' own `user.info.basic,video.list`). */
|
|
90
|
+
export function formatScopeParam(scopes) {
|
|
91
|
+
return scopes.join(',');
|
|
92
|
+
}
|
|
93
|
+
export function describeScope(scope) {
|
|
94
|
+
const known = SCOPE_CATALOG[scope];
|
|
95
|
+
return known
|
|
96
|
+
? { scope, label: known.label, product: known.product, known: true }
|
|
97
|
+
: { scope, label: scope, product: 'user', known: false };
|
|
98
|
+
}
|
|
99
|
+
export function describeScopes(scopes) {
|
|
100
|
+
return scopes.map(describeScope);
|
|
101
|
+
}
|
|
102
|
+
/** Screen order: the vendor's own product order, stable so the page is byte-identical on reload. */
|
|
103
|
+
const PRODUCT_ORDER = { user: 0, video: 1, portability: 2, research: 3, local: 4 };
|
|
104
|
+
export function sortScopesForConsent(scopes) {
|
|
105
|
+
return [...scopes].sort((a, b) => PRODUCT_ORDER[describeScope(a).product] - PRODUCT_ORDER[describeScope(b).product]);
|
|
106
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Options every TikTok-twin HTTP surface needs, independent of who owns the socket. */
|
|
2
|
+
export interface TikTokTwinFetchOptions {
|
|
3
|
+
root?: string;
|
|
4
|
+
readOnly?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The pack's whole HTTP surface as a plain `fetch` — Request in, Response out, no listener.
|
|
8
|
+
*
|
|
9
|
+
* This is the composable form (runtime contract R12b): a Worker / Durable Object entry has NO
|
|
10
|
+
* loopback ports, so it must mount a pack's handler IN-PROCESS. `createTikTokTwinServer` is
|
|
11
|
+
* nothing but `Bun.serve` wrapped around this closure, so the standalone (R1) and hosted surfaces
|
|
12
|
+
* are the SAME code — there is no second HTTP adaptation to drift.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createTikTokTwinFetch(options?: TikTokTwinFetchOptions): (request: Request) => Promise<Response>;
|
|
15
|
+
export declare function createTikTokTwinServer(options: {
|
|
16
|
+
root?: string;
|
|
17
|
+
port?: number;
|
|
18
|
+
readOnly?: boolean;
|
|
19
|
+
}): Promise<{
|
|
20
|
+
port: number;
|
|
21
|
+
stop: () => void;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* The authorization page is served BY THE TWIN, at the vendor's own path — there is no second
|
|
25
|
+
* "mirror" server to start. This alias exists so the `world-tiktok mirror` command and the journey
|
|
26
|
+
* harness have the conventional entry point, and it returns the very same server.
|
|
27
|
+
*/
|
|
28
|
+
export declare const createTikTokConsentServer: typeof createTikTokTwinServer;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// TikTok twin HTTP server — ONE server for the whole vendor surface, because Login Kit is one
|
|
2
|
+
// product spread over two hosts. Point `www.tiktok.com` (the authorization page) and
|
|
3
|
+
// `open.tiktokapis.com` (the OAuth + Display API endpoints) here and an unmodified TikTok
|
|
4
|
+
// integration completes a full authorization-code round trip against it.
|
|
5
|
+
//
|
|
6
|
+
// Two things this server does that a JSON-only twin server does not:
|
|
7
|
+
// • it can answer with HTML and with 302 redirects — the authorization page and the callback
|
|
8
|
+
// bounce are the protocol, not decoration, so the handler's `headers` (content-type, location)
|
|
9
|
+
// pass straight through rather than being flattened into a JSON envelope;
|
|
10
|
+
// • it tells the handler its OWN origin, so the consent form's action points BACK AT THE TWIN.
|
|
11
|
+
//
|
|
12
|
+
// The page needs no assets: the stylesheet is inlined and the screen ships no JavaScript, so there
|
|
13
|
+
// is no bundler and no disk read anywhere on the serve path (runtime contract R12b, R9).
|
|
14
|
+
import { serveHttp, statefulTwinManifest, twinPublicBase, worldNow } from '@volter/world-core';
|
|
15
|
+
import { UPLOAD_PATH } from "./tiktok-posting.js";
|
|
16
|
+
import { handleTikTokTwinRequest } from "./tiktok-twin.js";
|
|
17
|
+
/**
|
|
18
|
+
* The pack's whole HTTP surface as a plain `fetch` — Request in, Response out, no listener.
|
|
19
|
+
*
|
|
20
|
+
* This is the composable form (runtime contract R12b): a Worker / Durable Object entry has NO
|
|
21
|
+
* loopback ports, so it must mount a pack's handler IN-PROCESS. `createTikTokTwinServer` is
|
|
22
|
+
* nothing but `Bun.serve` wrapped around this closure, so the standalone (R1) and hosted surfaces
|
|
23
|
+
* are the SAME code — there is no second HTTP adaptation to drift.
|
|
24
|
+
*/
|
|
25
|
+
export function createTikTokTwinFetch(options = {}) {
|
|
26
|
+
const readOnly = options.readOnly ?? false;
|
|
27
|
+
return async function tiktokTwinFetch(request) {
|
|
28
|
+
const url = new URL(request.url);
|
|
29
|
+
// GET /twin — the discovery manifest (education inside the twin).
|
|
30
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
31
|
+
return Response.json(statefulTwinManifest({
|
|
32
|
+
vendor: 'tiktok',
|
|
33
|
+
twinOf: 'TikTok Login Kit, the Display API and the Content Posting API',
|
|
34
|
+
stores: 'developer apps, TikTok accounts and their videos (with the posted media), authorization codes, user access/refresh tokens, the authorization-page round trip and Content Posting publishes',
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
// the upload URL's chunk PUT carries BYTES; every other body is text (form or JSON)
|
|
38
|
+
const upload = url.pathname.replace(/\/+$/, '') === UPLOAD_PATH;
|
|
39
|
+
const raw = request.method === 'GET' || request.method === 'HEAD' ? new Uint8Array(0) : new Uint8Array(await request.arrayBuffer());
|
|
40
|
+
const body = upload ? '' : new TextDecoder().decode(raw);
|
|
41
|
+
const headers = {};
|
|
42
|
+
request.headers.forEach((value, key) => {
|
|
43
|
+
headers[key.toLowerCase()] = value;
|
|
44
|
+
});
|
|
45
|
+
const res = await handleTikTokTwinRequest({
|
|
46
|
+
method: request.method,
|
|
47
|
+
path: url.pathname + (url.search || ''),
|
|
48
|
+
body,
|
|
49
|
+
headers,
|
|
50
|
+
readOnly,
|
|
51
|
+
occurredAt: worldNow(),
|
|
52
|
+
origin: twinPublicBase(request),
|
|
53
|
+
callbackOrigin: url.origin,
|
|
54
|
+
...(upload ? { bytes: raw } : {}),
|
|
55
|
+
...(headers['x-volter-twin-original-host'] ? { originalHost: headers['x-volter-twin-original-host'].toLowerCase() } : {}),
|
|
56
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
57
|
+
});
|
|
58
|
+
const out = { ...(res.headers ?? {}) };
|
|
59
|
+
// a post's bytes (the media route): a range as bytes, a whole file as a stream of ranged reads;
|
|
60
|
+
// HEAD answers the headers alone
|
|
61
|
+
if (res.body instanceof Uint8Array || res.body instanceof ReadableStream) {
|
|
62
|
+
const media = request.method === 'HEAD' ? null : res.body;
|
|
63
|
+
return new Response(media, { status: res.status, headers: out });
|
|
64
|
+
}
|
|
65
|
+
// A string body is already rendered (HTML, or the empty body of a 302); anything else is the
|
|
66
|
+
// vendor's JSON.
|
|
67
|
+
if (typeof res.body === 'string') {
|
|
68
|
+
if (!out['content-type'] && res.body)
|
|
69
|
+
out['content-type'] = 'text/html; charset=utf-8';
|
|
70
|
+
return new Response(res.body, { status: res.status, headers: out });
|
|
71
|
+
}
|
|
72
|
+
out['content-type'] = out['content-type'] ?? 'application/json; charset=utf-8';
|
|
73
|
+
return new Response(JSON.stringify(res.body), { status: res.status, headers: out });
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export async function createTikTokTwinServer(options) {
|
|
77
|
+
const server = await serveHttp({
|
|
78
|
+
port: options.port ?? 0,
|
|
79
|
+
idleTimeout: 60,
|
|
80
|
+
fetch: createTikTokTwinFetch(options),
|
|
81
|
+
});
|
|
82
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The authorization page is served BY THE TWIN, at the vendor's own path — there is no second
|
|
86
|
+
* "mirror" server to start. This alias exists so the `world-tiktok mirror` command and the journey
|
|
87
|
+
* harness have the conventional entry point, and it returns the very same server.
|
|
88
|
+
*/
|
|
89
|
+
export const createTikTokConsentServer = createTikTokTwinServer;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { type TwinResource } from '@volter/world-core';
|
|
2
|
+
/** A credential as the log keeps it: its SHA-256 (hex), never the credential. */
|
|
3
|
+
export declare function secretKey(value: string): string;
|
|
4
|
+
export declare const SERVICE = "tiktok";
|
|
5
|
+
/**
|
|
6
|
+
* THE CREDENTIAL ROWS ARE BOOKKEEPING, AND THEY NEVER NAME THEIR CREDENTIAL. An authorization code,
|
|
7
|
+
* an access / refresh / client token and a rate window are the twin's own records: nothing about them
|
|
8
|
+
* crosses to TikTok, so they are `_`-prefixed (world-core `isTwinBookkeeping`) and never deployed or
|
|
9
|
+
* pushed. Each is keyed by the SHA-256 of the credential (`secretKey`), never the credential — the
|
|
10
|
+
* YouTube `_token` pattern — so no event log, resource record or changeset a World keeps holds a
|
|
11
|
+
* bearer in the clear. A presented credential is hashed and looked up.
|
|
12
|
+
*/
|
|
13
|
+
export declare const BK_CODE = "_authorization_code";
|
|
14
|
+
export declare const BK_ACCESS = "_access_token";
|
|
15
|
+
export declare const BK_REFRESH = "_refresh_token";
|
|
16
|
+
export declare const BK_CLIENT = "_client_token";
|
|
17
|
+
export declare const BK_RATE = "_rate_window";
|
|
18
|
+
export declare const RESOURCE_TYPES: readonly ["oauth_client", "account", "session", "auth_request", "_authorization_code", "_access_token", "_refresh_token", "_client_token", "grant", "video", "publish", "_rate_window"];
|
|
19
|
+
export type ResourceType = (typeof RESOURCE_TYPES)[number];
|
|
20
|
+
export type Row = TwinResource & Record<string, any>;
|
|
21
|
+
export declare function readAll(root: string | undefined): Row[];
|
|
22
|
+
export declare function readType(root: string | undefined, type: ResourceType): Row[];
|
|
23
|
+
export declare function readOne(root: string | undefined, type: ResourceType, id: string): Row | undefined;
|
|
24
|
+
/** The next `rev` for a subject that is being rewritten in place (survives deletes/tombstones). */
|
|
25
|
+
export declare function nextRev(root: string | undefined, type: ResourceType, id: string): number;
|
|
26
|
+
/** The next revision from a snapshot already held under the kernel projection lock. */
|
|
27
|
+
export declare function nextRevIn(resources: readonly TwinResource[], type: ResourceType, id: string): number;
|
|
28
|
+
export type WriteOpts = {
|
|
29
|
+
root?: string;
|
|
30
|
+
occurredAt?: string;
|
|
31
|
+
};
|
|
32
|
+
/** Seeding also needs the origin the twin was reached on, so the demo app's registered callback
|
|
33
|
+
* points back at THIS twin rather than at a baked-in port (runtime contract R7). */
|
|
34
|
+
export type SeedOpts = WriteOpts & {
|
|
35
|
+
origin?: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function write(type: ResourceType, id: string, operation: string, fields: Record<string, unknown>, opts: WriteOpts): Promise<Row>;
|
|
38
|
+
/** A state-dependent one-row update whose read/revision/write decision is one kernel transaction. */
|
|
39
|
+
export declare function writeAtomic(type: ResourceType, id: string, operation: string, fields: (resources: readonly TwinResource[]) => Record<string, unknown>, opts: WriteOpts): Promise<Row>;
|
|
40
|
+
/**
|
|
41
|
+
* A TikTok user access token. The vendor's own example is `act.example12345Example12345Example`
|
|
42
|
+
* (User Access Token Management guide) — a dotted prefix plus an opaque tail, and integrations do
|
|
43
|
+
* key on the prefix. The twin reproduces the shape; the bytes are not the vendor's.
|
|
44
|
+
*/
|
|
45
|
+
export declare const mintAccessToken: (root: string | undefined, at: number, subject: string) => string;
|
|
46
|
+
/** A TikTok refresh token — the docs' `rft.example12345Example12345Example`. */
|
|
47
|
+
export declare const mintRefreshToken: (root: string | undefined, at: number, subject: string) => string;
|
|
48
|
+
/** An app-only client access token — the client-credentials guide's `clt.example12345…`. */
|
|
49
|
+
export declare const mintClientToken: (root: string | undefined, at: number, subject: string) => string;
|
|
50
|
+
/**
|
|
51
|
+
* An authorization code.
|
|
52
|
+
*
|
|
53
|
+
* EXTRAPOLATION, and a deliberate one. The token endpoint's parameter table says the `code` is
|
|
54
|
+
* "The authorization code from the web, iOS, Android or desktop authorization callback" and the
|
|
55
|
+
* summary page says it must be "URL decoded" — a note that is only meaningful if the code carries
|
|
56
|
+
* characters a query string must percent-encode. TikTok's codes are widely reported to end in a
|
|
57
|
+
* `*!<n>!` marker (`…%2A%211%21` on the wire), so the twin mints that shape: an integration that
|
|
58
|
+
* forgets to decode the callback parameter then fails HERE, locally, instead of against the real
|
|
59
|
+
* vendor. The exact live format is pinned by `tiktok.token.code_format` (todo).
|
|
60
|
+
*/
|
|
61
|
+
export declare const mintAuthorizationCode: (root: string | undefined, at: number, subject: string) => string;
|
|
62
|
+
/**
|
|
63
|
+
* A Content Posting `publish_id`, in the two shapes the references print: `v_pub_file~v2-1.123456789`
|
|
64
|
+
* for a Direct Post and `v_inbox_file~v2.123456789` for an upload to the creator's inbox.
|
|
65
|
+
*/
|
|
66
|
+
export declare const mintPublishId: (mode: "direct" | "inbox") => string;
|
|
67
|
+
/** The upload_id in an upload_url's query (the references print `upload_id=67890`). */
|
|
68
|
+
export declare const mintUploadId: () => string;
|
|
69
|
+
/** A published post's id — the 19-digit item id the Video Object carries and status/fetch reports in
|
|
70
|
+
* `publicaly_available_post_id`. */
|
|
71
|
+
export declare const mintPostId: () => string;
|
|
72
|
+
/** A TikTok client key — the developer portal hands out an `aw…` string for a web app. */
|
|
73
|
+
export declare const mintClientKey: (root: string | undefined, instant: string) => string;
|
|
74
|
+
/**
|
|
75
|
+
* The `open_id` a given app sees a given account as — PER (client_key, account) and STABLE, which
|
|
76
|
+
* is the whole distinction the vendor draws between `open_id` ("The TikTok user's unique
|
|
77
|
+
* identifier", app-scoped) and `union_id` (the same human across one developer's apps). No count
|
|
78
|
+
* and no instant enter this seed: re-authorizing must hand the app back the SAME open_id, and two
|
|
79
|
+
* different apps must see two different ones.
|
|
80
|
+
*/
|
|
81
|
+
export declare const openIdFor: (clientKey: string, accountId: string) => string;
|
|
82
|
+
/**
|
|
83
|
+
* Twin-internal handle for an authorize screen in flight (never leaves the twin's own pages) —
|
|
84
|
+
* `ar_` + 32 hex, a pure function of (request, stored state).
|
|
85
|
+
*
|
|
86
|
+
* An authorize request whose parameters ALREADY name an unsettled pending row reuses that row's
|
|
87
|
+
* id: pressing reload on the authorize screen is the same screen, not a new one.
|
|
88
|
+
*/
|
|
89
|
+
export declare function authRequestIdFor(root: string | undefined, occurredAt: string, fields: Record<string, unknown>): string;
|
|
90
|
+
/** The seeded demo app's client key. TikTok web apps are issued an `aw…` key by the developer
|
|
91
|
+
* portal; a world running a real integration registers ITS OWN key through `POST /_twin/clients`
|
|
92
|
+
* (there is no TikTok API that creates an app, so this is the only honest door). */
|
|
93
|
+
export declare const DEFAULT_CLIENT_KEY = "awtwindemoapp00001";
|
|
94
|
+
export declare const DEFAULT_CLIENT_SECRET = "twin-demo-client-secret-000000000000";
|
|
95
|
+
export type SeedAccount = {
|
|
96
|
+
/** The subject id: the account's `union_id`, which is the vendor's own cross-app user key. */
|
|
97
|
+
unionId: string;
|
|
98
|
+
username: string;
|
|
99
|
+
displayName: string;
|
|
100
|
+
avatarUrl: string;
|
|
101
|
+
avatarUrl100: string;
|
|
102
|
+
avatarLargeUrl: string;
|
|
103
|
+
bioDescription: string;
|
|
104
|
+
profileDeepLink: string;
|
|
105
|
+
isVerified: boolean;
|
|
106
|
+
followerCount: number;
|
|
107
|
+
followingCount: number;
|
|
108
|
+
likesCount: number;
|
|
109
|
+
videoCount: number;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* TikTok `union_id`s are v4-shaped UUIDs. The seeded personas take the ALL-ZERO corner of that
|
|
113
|
+
* space (`00000000-0000-4000-8000-…`), which a real v4 generator will not produce, so a locally
|
|
114
|
+
* seeded persona can never collide with a pulled real account (the datadog EVENT_ID_BASE / xidentity
|
|
115
|
+
* 9e18 precedent: the kernel projects twin-side writes over observations, so a colliding id would
|
|
116
|
+
* keep serving the local row).
|
|
117
|
+
*/
|
|
118
|
+
export declare const DEFAULT_ACCOUNTS: SeedAccount[];
|
|
119
|
+
export type SeedVideo = {
|
|
120
|
+
id: string;
|
|
121
|
+
ownerUnionId: string;
|
|
122
|
+
title: string;
|
|
123
|
+
videoDescription: string;
|
|
124
|
+
/** UTC unix epoch SECONDS — the vendor's own `create_time` unit. */
|
|
125
|
+
createTime: number;
|
|
126
|
+
duration: number;
|
|
127
|
+
height: number;
|
|
128
|
+
width: number;
|
|
129
|
+
likeCount: number;
|
|
130
|
+
commentCount: number;
|
|
131
|
+
shareCount: number;
|
|
132
|
+
viewCount: number;
|
|
133
|
+
};
|
|
134
|
+
/** TikTok video ids ("also called item_id") are 19-digit numeric strings. The seeded ones start at
|
|
135
|
+
* 7.0e18, inside the shape but pinned to constants so the Display API reads are deterministic. */
|
|
136
|
+
export declare const DEFAULT_VIDEOS: SeedVideo[];
|
|
137
|
+
/**
|
|
138
|
+
* The redirect URIs the seeded demo app registers.
|
|
139
|
+
*
|
|
140
|
+
* Runtime contract R7: a twin NEVER bakes a port into served content or config. The callbacks are
|
|
141
|
+
* DERIVED from the origin the twin was actually reached on (`TikTokRequest.origin`, which
|
|
142
|
+
* tiktok-server fills from the URL the request arrived at). A caller that declares no origin — an
|
|
143
|
+
* in-process call — seeds no callbacks at all and registers its own through the twin door.
|
|
144
|
+
*/
|
|
145
|
+
export declare function defaultRedirectUris(origin?: string): string[];
|
|
146
|
+
/**
|
|
147
|
+
* Materialise the default app + accounts + videos + session once per root. Idempotent by subject
|
|
148
|
+
* id: re-running it over a seeded root writes nothing new, and it NEVER overwrites an
|
|
149
|
+
* operator-registered app, account, video or session choice.
|
|
150
|
+
*/
|
|
151
|
+
export declare function ensureSeed(opts: SeedOpts): Promise<void>;
|
|
152
|
+
/** The account the tiktok.com browser session is signed in as (the one the sheet consents). */
|
|
153
|
+
export declare function sessionAccount(root: string | undefined): Row | undefined;
|
|
154
|
+
/**
|
|
155
|
+
* Is `candidate` a registered Redirect URI for this app? TikTok's parameter table says the
|
|
156
|
+
* redirect_uri "must match one of the redirect URIs you registered", so the twin compares exactly.
|
|
157
|
+
*
|
|
158
|
+
* EVIDENCE BOUNDARY: the docs do not state whether the comparison is exact or prefix-based, nor
|
|
159
|
+
* whether TikTok's documented desktop-only allowance for `http://127.0.0.1:*` wildcards extends to
|
|
160
|
+
* web apps. Exact is the strict reading, and a loosely-matching twin would hide the single most
|
|
161
|
+
* common Login Kit integration bug; `tiktok.authorize.redirect_uri_matching` (todo) pins the live
|
|
162
|
+
* rule and `tiktok.authorize.redirect_uri_scheme_rules` pins the scheme/port restrictions.
|
|
163
|
+
*/
|
|
164
|
+
export declare function redirectUriAllowed(client: Row, candidate: string): boolean;
|