@volter/twin-tiktok 0.1.0 → 0.1.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/client/tiktok-mirror.tsx +6 -1
- package/dist/client/tiktok-mirror.bundle.js +260 -263
- package/dist/client/tiktok-mirror.js +10 -0
- package/dist/client/tiktok-mirror.tsx +6 -1
- package/dist/src/tiktok-mirror-ui.d.ts +12 -0
- package/dist/src/tiktok-mirror-ui.js +31 -3
- package/dist/src/tiktok-server.js +14 -4
- package/package.json +3 -3
- package/src/tiktok-mirror-ui.ts +29 -3
- package/src/tiktok-server.ts +14 -4
|
@@ -15,6 +15,15 @@ const SESSION_TOKEN = 'tiktok-mirror.token';
|
|
|
15
15
|
function readSession() {
|
|
16
16
|
if (typeof sessionStorage === 'undefined')
|
|
17
17
|
return null;
|
|
18
|
+
// the World signed these screens in as the account its config names (`signIn.as`): taken once, as a sign-in would
|
|
19
|
+
// (off the page once taken, and not again in this tab once the person signs out)
|
|
20
|
+
const w = window;
|
|
21
|
+
const given = sessionStorage.getItem('volter.signedOut') ? undefined : w.__VOLTER_SIGN_IN__;
|
|
22
|
+
delete w.__VOLTER_SIGN_IN__;
|
|
23
|
+
if (given && !sessionStorage.getItem(SESSION_TOKEN)) {
|
|
24
|
+
sessionStorage.setItem(SESSION_HANDLE, given.account);
|
|
25
|
+
sessionStorage.setItem(SESSION_TOKEN, given.token);
|
|
26
|
+
}
|
|
18
27
|
const handle = sessionStorage.getItem(SESSION_HANDLE);
|
|
19
28
|
const token = sessionStorage.getItem(SESSION_TOKEN);
|
|
20
29
|
return handle && token ? { handle, token } : null;
|
|
@@ -270,6 +279,7 @@ function App() {
|
|
|
270
279
|
const signOut = useCallback(() => {
|
|
271
280
|
sessionStorage.removeItem(SESSION_HANDLE);
|
|
272
281
|
sessionStorage.removeItem(SESSION_TOKEN);
|
|
282
|
+
sessionStorage.setItem('volter.signedOut', '1');
|
|
273
283
|
setSession(null);
|
|
274
284
|
setMe(null);
|
|
275
285
|
go('login');
|
|
@@ -22,6 +22,11 @@ type Wire = { status: number; body: TtRow };
|
|
|
22
22
|
|
|
23
23
|
function readSession(): Session | null {
|
|
24
24
|
if (typeof sessionStorage === 'undefined') return null;
|
|
25
|
+
// the World signed these screens in as the account its config names (`signIn.as`): taken once, as a sign-in would
|
|
26
|
+
// (off the page once taken, and not again in this tab once the person signs out)
|
|
27
|
+
const w = window as { __VOLTER_SIGN_IN__?: { account: string; token: string } };
|
|
28
|
+
const given = sessionStorage.getItem('volter.signedOut') ? undefined : w.__VOLTER_SIGN_IN__; delete w.__VOLTER_SIGN_IN__;
|
|
29
|
+
if (given && !sessionStorage.getItem(SESSION_TOKEN)) { sessionStorage.setItem(SESSION_HANDLE, given.account); sessionStorage.setItem(SESSION_TOKEN, given.token); }
|
|
25
30
|
const handle = sessionStorage.getItem(SESSION_HANDLE);
|
|
26
31
|
const token = sessionStorage.getItem(SESSION_TOKEN);
|
|
27
32
|
return handle && token ? { handle, token } : null;
|
|
@@ -442,7 +447,7 @@ function App() {
|
|
|
442
447
|
|
|
443
448
|
const signOut = useCallback(() => {
|
|
444
449
|
sessionStorage.removeItem(SESSION_HANDLE);
|
|
445
|
-
sessionStorage.removeItem(SESSION_TOKEN);
|
|
450
|
+
sessionStorage.removeItem(SESSION_TOKEN); sessionStorage.setItem('volter.signedOut', '1');
|
|
446
451
|
setSession(null);
|
|
447
452
|
setMe(null);
|
|
448
453
|
go('login');
|
|
@@ -47,3 +47,15 @@ export declare function createTiktokMirrorServer(options: {
|
|
|
47
47
|
export declare function tiktokMirrorHtml(): string;
|
|
48
48
|
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
49
49
|
export declare function tiktokMirrorStyles(): Promise<string>;
|
|
50
|
+
/** The account `as` names (a username, with or without its @) and a user access token minted for it: what TikTok's
|
|
51
|
+
* screens log in with when the World's config says `signIn: { as }` (the World hands them over, served-world.ts).
|
|
52
|
+
* The token is issued through the twin's own door (`/_twin/tokens`), as the World's seed does; a stored bearer is
|
|
53
|
+
* never read back. Like every TikTok user access token it lives 24 hours of World time (the door sets no other
|
|
54
|
+
* life); the screens then log out on the 401. Null when the World holds no such account, or the door refuses. */
|
|
55
|
+
export declare function tiktokMirrorSignIn(as: string, ctx: {
|
|
56
|
+
root: string;
|
|
57
|
+
twin: (path: string, init?: RequestInit) => Promise<Response>;
|
|
58
|
+
}): Promise<{
|
|
59
|
+
account: string;
|
|
60
|
+
token: string;
|
|
61
|
+
} | null>;
|
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
// PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's own
|
|
21
21
|
// fetch adapter as its API backend and reads every byte of state back over the wire.
|
|
22
22
|
import { readFile } from 'node:fs/promises';
|
|
23
|
-
import { bundleClient, fileResponse, serveHttp } from '@volter/world-core';
|
|
23
|
+
import { twinResources, filePathOf, bundleClient, fileResponse, serveHttp } from '@volter/world-core';
|
|
24
24
|
import { createTikTokTwinFetch } from "./tiktok-server.js";
|
|
25
|
-
|
|
26
|
-
const
|
|
25
|
+
import { DEFAULT_CLIENT_KEY } from "./tiktok-store.js";
|
|
26
|
+
const CLIENT_ENTRY = () => filePathOf(new URL('../client/tiktok-mirror.tsx', import.meta.url)); // lazy: workerd rejects a top-level relative import.meta.url
|
|
27
|
+
const CLIENT_CSS = () => filePathOf(new URL('../client/tiktok-mirror.css', import.meta.url)); // lazy: same reason
|
|
27
28
|
/** The user fields the profile reads — every one the Display API gates behind the three user scopes. */
|
|
28
29
|
export const PROFILE_FIELDS = 'open_id,union_id,avatar_url,display_name,username,bio_description,is_verified,follower_count,following_count,likes_count,video_count';
|
|
29
30
|
/** The fields a token holding only user.info.basic may ask for (the header then carries no counts). */
|
|
@@ -157,3 +158,30 @@ export function tiktokMirrorHtml() {
|
|
|
157
158
|
export function tiktokMirrorStyles() {
|
|
158
159
|
return readFile(CLIENT_CSS(), 'utf8');
|
|
159
160
|
}
|
|
161
|
+
/** The account `as` names (a username, with or without its @) and a user access token minted for it: what TikTok's
|
|
162
|
+
* screens log in with when the World's config says `signIn: { as }` (the World hands them over, served-world.ts).
|
|
163
|
+
* The token is issued through the twin's own door (`/_twin/tokens`), as the World's seed does; a stored bearer is
|
|
164
|
+
* never read back. Like every TikTok user access token it lives 24 hours of World time (the door sets no other
|
|
165
|
+
* life); the screens then log out on the 401. Null when the World holds no such account, or the door refuses. */
|
|
166
|
+
export async function tiktokMirrorSignIn(as, ctx) {
|
|
167
|
+
const wanted = as.trim().replace(/^@/, '').toLowerCase();
|
|
168
|
+
const account = twinResources('tiktok', ctx.root).find((r) => r.type === 'account' && r.deleted !== true && String(r.username ?? '').toLowerCase() === wanted);
|
|
169
|
+
if (!account)
|
|
170
|
+
return null;
|
|
171
|
+
// only what the screens read, and only what the app's grant for this account already holds: minting unions its scopes
|
|
172
|
+
// into that grant, and opening a page must never widen what the app under test was granted
|
|
173
|
+
const grant = twinResources('tiktok', ctx.root).find((r) => r.type === 'grant' && r.id === `${DEFAULT_CLIENT_KEY}:${account.id}`);
|
|
174
|
+
const held = Array.isArray(grant?.scopes) ? grant.scopes.map(String) : [];
|
|
175
|
+
const scopes = ['user.info.basic', 'user.info.profile', 'user.info.stats', 'video.list'].filter((s) => held.includes(s));
|
|
176
|
+
if (!scopes.includes('user.info.basic'))
|
|
177
|
+
return null;
|
|
178
|
+
const res = await ctx.twin('/_twin/tokens', {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { 'content-type': 'application/json' },
|
|
181
|
+
body: JSON.stringify({ union_id: account.id, scopes }),
|
|
182
|
+
});
|
|
183
|
+
if (!res.ok)
|
|
184
|
+
return null;
|
|
185
|
+
const body = await res.json().catch(() => null);
|
|
186
|
+
return typeof body?.access_token === 'string' ? { account: String(account.username), token: body.access_token } : null;
|
|
187
|
+
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
//
|
|
12
12
|
// The page needs no assets: the stylesheet is inlined and the screen ships no JavaScript, so there
|
|
13
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';
|
|
14
|
+
import { isReadOnlyRequest, serveHttp, statefulTwinManifest, twinPublicBase, withRequestScopes, worldNow } from '@volter/world-core';
|
|
15
|
+
import { oauthErrorCode } from "./tiktok-errors.js";
|
|
15
16
|
import { UPLOAD_PATH } from "./tiktok-posting.js";
|
|
16
17
|
import { handleTikTokTwinRequest } from "./tiktok-twin.js";
|
|
17
18
|
/**
|
|
@@ -23,8 +24,12 @@ import { handleTikTokTwinRequest } from "./tiktok-twin.js";
|
|
|
23
24
|
* are the SAME code — there is no second HTTP adaptation to drift.
|
|
24
25
|
*/
|
|
25
26
|
export function createTikTokTwinFetch(options = {}) {
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
// a read-only request (x-volter-read-only, a World's read token) is a request to a read-only twin: the
|
|
28
|
+
// authorize page never signs anyone in or mints a code, no token is minted or revoked, the Display
|
|
29
|
+
// API reads are served without spending the app's rate window (D3), and the kernel refuses any write
|
|
30
|
+
// that slips past as TikTok's own flat OAuth error
|
|
31
|
+
return withRequestScopes(async function tiktokTwinFetch(request) {
|
|
32
|
+
const readOnly = (options.readOnly ?? false) || isReadOnlyRequest(request);
|
|
28
33
|
const url = new URL(request.url);
|
|
29
34
|
// GET /twin — the discovery manifest (education inside the twin).
|
|
30
35
|
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
@@ -71,7 +76,12 @@ export function createTikTokTwinFetch(options = {}) {
|
|
|
71
76
|
}
|
|
72
77
|
out['content-type'] = out['content-type'] ?? 'application/json; charset=utf-8';
|
|
73
78
|
return new Response(JSON.stringify(res.body), { status: res.status, headers: out });
|
|
74
|
-
}
|
|
79
|
+
}, {
|
|
80
|
+
refuse: () => {
|
|
81
|
+
const refused = oauthErrorCode('temporarily_unavailable', worldNow(), 405);
|
|
82
|
+
return new Response(JSON.stringify(refused.body), { status: refused.status, headers: refused.headers });
|
|
83
|
+
},
|
|
84
|
+
});
|
|
75
85
|
}
|
|
76
86
|
export async function createTikTokTwinServer(options) {
|
|
77
87
|
const server = await serveHttp({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-tiktok",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Local TikTok twin — the real tiktok.com authorization page, the full Login Kit OAuth round trip (token, refresh, revoke, client_credentials) and the Display API user/video reads with TikTok's own comma-separated scopes, per-app open_id, field-level scope gating and both of its error envelopes, so an unmodified TikTok integration completes sign-in-with-TikTok against it. Built on @volter/world-core.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -57,14 +57,14 @@
|
|
|
57
57
|
"react-dom": "^19.2.7"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@volter/world-core": "2.0.
|
|
60
|
+
"@volter/world-core": "2.0.2"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/bun": "^1.2.20",
|
|
64
64
|
"@types/node": "^24.0.0",
|
|
65
65
|
"@types/react": "^19.2.17",
|
|
66
66
|
"@types/react-dom": "^19.2.3",
|
|
67
|
-
"@volter/world-core": "2.0.
|
|
67
|
+
"@volter/world-core": "2.0.2",
|
|
68
68
|
"@volter/world-tooling": "0.1.0",
|
|
69
69
|
"typescript": "^5.9.0"
|
|
70
70
|
},
|
package/src/tiktok-mirror-ui.ts
CHANGED
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
// PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's own
|
|
21
21
|
// fetch adapter as its API backend and reads every byte of state back over the wire.
|
|
22
22
|
import { readFile } from 'node:fs/promises';
|
|
23
|
-
import { bundleClient, fileResponse, serveHttp } from '@volter/world-core';
|
|
23
|
+
import { twinResources, filePathOf, bundleClient, fileResponse, serveHttp } from '@volter/world-core';
|
|
24
24
|
import { createTikTokTwinFetch } from './tiktok-server.ts';
|
|
25
|
+
import { DEFAULT_CLIENT_KEY } from './tiktok-store.ts';
|
|
25
26
|
|
|
26
|
-
const CLIENT_ENTRY = () => new URL('../client/tiktok-mirror.tsx', import.meta.url)
|
|
27
|
-
const CLIENT_CSS = () => new URL('../client/tiktok-mirror.css', import.meta.url)
|
|
27
|
+
const CLIENT_ENTRY = () => filePathOf(new URL('../client/tiktok-mirror.tsx', import.meta.url)); // lazy: workerd rejects a top-level relative import.meta.url
|
|
28
|
+
const CLIENT_CSS = () => filePathOf(new URL('../client/tiktok-mirror.css', import.meta.url)); // lazy: same reason
|
|
28
29
|
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
30
31
|
// Pure, dependency-free helpers (importable by the React client; Bun tree-shakes the server-only
|
|
@@ -165,3 +166,28 @@ export function tiktokMirrorHtml(): string {
|
|
|
165
166
|
export function tiktokMirrorStyles(): Promise<string> {
|
|
166
167
|
return readFile(CLIENT_CSS(), 'utf8');
|
|
167
168
|
}
|
|
169
|
+
|
|
170
|
+
/** The account `as` names (a username, with or without its @) and a user access token minted for it: what TikTok's
|
|
171
|
+
* screens log in with when the World's config says `signIn: { as }` (the World hands them over, served-world.ts).
|
|
172
|
+
* The token is issued through the twin's own door (`/_twin/tokens`), as the World's seed does; a stored bearer is
|
|
173
|
+
* never read back. Like every TikTok user access token it lives 24 hours of World time (the door sets no other
|
|
174
|
+
* life); the screens then log out on the 401. Null when the World holds no such account, or the door refuses. */
|
|
175
|
+
export async function tiktokMirrorSignIn(as: string, ctx: { root: string; twin: (path: string, init?: RequestInit) => Promise<Response> }): Promise<{ account: string; token: string } | null> {
|
|
176
|
+
const wanted = as.trim().replace(/^@/, '').toLowerCase();
|
|
177
|
+
const account = twinResources('tiktok', ctx.root).find((r) => r.type === 'account' && r.deleted !== true && String(r.username ?? '').toLowerCase() === wanted);
|
|
178
|
+
if (!account) return null;
|
|
179
|
+
// only what the screens read, and only what the app's grant for this account already holds: minting unions its scopes
|
|
180
|
+
// into that grant, and opening a page must never widen what the app under test was granted
|
|
181
|
+
const grant = twinResources('tiktok', ctx.root).find((r) => r.type === 'grant' && r.id === `${DEFAULT_CLIENT_KEY}:${account.id}`);
|
|
182
|
+
const held = Array.isArray(grant?.scopes) ? (grant.scopes as unknown[]).map(String) : [];
|
|
183
|
+
const scopes = ['user.info.basic', 'user.info.profile', 'user.info.stats', 'video.list'].filter((s) => held.includes(s));
|
|
184
|
+
if (!scopes.includes('user.info.basic')) return null;
|
|
185
|
+
const res = await ctx.twin('/_twin/tokens', {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'content-type': 'application/json' },
|
|
188
|
+
body: JSON.stringify({ union_id: account.id, scopes }),
|
|
189
|
+
});
|
|
190
|
+
if (!res.ok) return null;
|
|
191
|
+
const body = await res.json().catch(() => null) as { access_token?: unknown } | null;
|
|
192
|
+
return typeof body?.access_token === 'string' ? { account: String(account.username), token: body.access_token } : null;
|
|
193
|
+
}
|
package/src/tiktok-server.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
//
|
|
12
12
|
// The page needs no assets: the stylesheet is inlined and the screen ships no JavaScript, so there
|
|
13
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';
|
|
14
|
+
import { isReadOnlyRequest, serveHttp, statefulTwinManifest, twinPublicBase, withRequestScopes, worldNow } from '@volter/world-core';
|
|
15
|
+
import { oauthErrorCode } from './tiktok-errors.ts';
|
|
15
16
|
import { UPLOAD_PATH } from './tiktok-posting.ts';
|
|
16
17
|
import { handleTikTokTwinRequest } from './tiktok-twin.ts';
|
|
17
18
|
|
|
@@ -30,8 +31,12 @@ export interface TikTokTwinFetchOptions {
|
|
|
30
31
|
* are the SAME code — there is no second HTTP adaptation to drift.
|
|
31
32
|
*/
|
|
32
33
|
export function createTikTokTwinFetch(options: TikTokTwinFetchOptions = {}): (request: Request) => Promise<Response> {
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
// a read-only request (x-volter-read-only, a World's read token) is a request to a read-only twin: the
|
|
35
|
+
// authorize page never signs anyone in or mints a code, no token is minted or revoked, the Display
|
|
36
|
+
// API reads are served without spending the app's rate window (D3), and the kernel refuses any write
|
|
37
|
+
// that slips past as TikTok's own flat OAuth error
|
|
38
|
+
return withRequestScopes(async function tiktokTwinFetch(request: Request): Promise<Response> {
|
|
39
|
+
const readOnly = (options.readOnly ?? false) || isReadOnlyRequest(request);
|
|
35
40
|
const url = new URL(request.url);
|
|
36
41
|
// GET /twin — the discovery manifest (education inside the twin).
|
|
37
42
|
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
@@ -80,7 +85,12 @@ export function createTikTokTwinFetch(options: TikTokTwinFetchOptions = {}): (re
|
|
|
80
85
|
}
|
|
81
86
|
out['content-type'] = out['content-type'] ?? 'application/json; charset=utf-8';
|
|
82
87
|
return new Response(JSON.stringify(res.body), { status: res.status, headers: out });
|
|
83
|
-
}
|
|
88
|
+
}, {
|
|
89
|
+
refuse: () => {
|
|
90
|
+
const refused = oauthErrorCode('temporarily_unavailable', worldNow(), 405);
|
|
91
|
+
return new Response(JSON.stringify(refused.body), { status: refused.status, headers: refused.headers });
|
|
92
|
+
},
|
|
93
|
+
});
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
export async function createTikTokTwinServer(options: { root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; stop: () => void }> {
|