fix(vk): friend-code link is the plain vk.com/app link (VK strips iframe query payload)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m33s

The contour diagnostic confirmed VK forwards only the signed vk_* params to the iframe — a
custom payload on the app link is dropped (the '#' eaten by the vk.com SPA, a '?' query
stripped), so the friend-code cannot ride the link. The VK share link is now just
vk.com/app<id>; the recipient enters the copied code by hand (VKWebAppCopyText works). The
vkStartParam reader + bootVK routing stay as a no-op, ready for a post-moderation channel.
Removes the temporary deep-link diagnostic.
This commit is contained in:
Ilia Denisov
2026-06-29 22:58:43 +02:00
parent 0ea9764a0d
commit d76f1f4026
5 changed files with 28 additions and 37 deletions
+8 -5
View File
@@ -90,11 +90,14 @@ The bridge talks to the embedding VK client over postMessage; it is NOT an exter
it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import
it **lazily** so the pure URL helpers stay node-test-importable. it **lazily** so the pure URL helpers stay node-test-importable.
**Deep links** (direct-link launch): `https://vk.com/app<id>#<payload>` forwards everything after the **Deep links — NOT possible on VK (confirmed on the contour).** The VK iframe receives ONLY the signed
`#` to the app as the **`hash` launch query parameter** (it is also in `location.hash`, but that `vk_*` launch params (+ `sign`); VK strips any custom data from the app link. The documented
collides with our hash router, so read the `hash` query param). `?` query strings are NOT supported `vk.com/app<id>#<payload>` form is eaten by the vk.com SPA (which owns the URL hash), and a
for VK direct links. The friend-code invite is built as `vk.com/app<id>#f<code>` — the app id comes `vk.com/app<id>?hash=<payload>` query is dropped (the diagnostic showed `rawSearch` with only `vk_*`
from `vk_app_id` in the launch params (no `VITE_VK_APP_ID` needed) — and read back via `vkStartParam`. and an empty `hash`). So the friend-code invite link is just `vk.com/app<id>` (`vkShareLink`, app id
from `vk_app_id`); the recipient enters the **copied code by hand** (`VKWebAppCopyText` works). The
`vkStartParam` reader + the `bootVK` routing stay as a no-op today, ready if a post-moderation VK
channel (e.g. an invite API) ever delivers a payload.
## 5. Test mode (to verify before moderation) ## 5. Test mode (to verify before moderation)
+9 -20
View File
@@ -751,19 +751,12 @@ async function bootVK(): Promise<void> {
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
try { try {
await adoptSession(await gateway.authVK(params, displayName)); await adoptSession(await gateway.authVK(params, displayName));
// A VK direct-link deep link (vk.com/app<id>?hash=<param>) rides in as the `hash` launch param. // VK forwards only the signed vk_* params to the iframe — a custom payload on the app link
const sp = vkStartParam(); // (whether after '#', eaten by the vk.com SPA, or as a '?' query, dropped) never reaches it,
const diag = app.blocked ? 'blocked' : await routeStartParam(sp); // confirmed on the contour. So there is no friend-code auto-redeem on VK in test mode: the
// TEMP VK deep-link diagnostic (remove after the contour catch): VK launches are not // launch lands on the lobby. vkStartParam stays as the reader for a future (post-moderation)
// reproducible locally and the cold-boot path lands unexpectedly on the lobby, so surface // deep-link channel, and routes the lobby for an empty payload today.
// the raw query, the parsed payload, the redeem outcome and the final route. if (!app.blocked) await routeStartParam(vkStartParam());
console.log('[VK deeplink]', {
rawSearch: typeof location !== 'undefined' ? location.search : '',
hash: sp,
outcome: diag,
route: router.route.name,
});
showToast(`VK dl: "${sp || '∅'}" -> ${diag}`);
app.bootError = false; app.bootError = false;
return; return;
} catch (err) { } catch (err) {
@@ -825,12 +818,12 @@ export async function retryTelegramLaunch(): Promise<void> {
* specific game, the friends screen with a friend-code redemption, or the lobby * specific game, the friends screen with a friend-code redemption, or the lobby
* (where invitations surface as a badge). * (where invitations surface as a badge).
*/ */
async function routeStartParam(param: string): Promise<string> { async function routeStartParam(param: string): Promise<void> {
const link = parseStartParam(param); const link = parseStartParam(param);
switch (link.kind) { switch (link.kind) {
case 'game': case 'game':
navigate(`/game/${link.id}`); navigate(`/game/${link.id}`);
return 'game:' + link.id; return;
case 'friendCode': case 'friendCode':
try { try {
const friend = await gateway.friendCodeRedeem(link.code); const friend = await gateway.friendCodeRedeem(link.code);
@@ -843,7 +836,6 @@ async function routeStartParam(param: string): Promise<string> {
showToast(t('friends.added', { name: friend.displayName })); showToast(t('friends.added', { name: friend.displayName }));
} }
void refreshNotifications(); void refreshNotifications();
return 'friendCode ok: ' + friend.displayName;
} catch (err) { } catch (err) {
const code = err instanceof GatewayError ? err.code : ''; const code = err instanceof GatewayError ? err.code : '';
if (code === 'self_relation') { if (code === 'self_relation') {
@@ -851,22 +843,19 @@ async function routeStartParam(param: string): Promise<string> {
// scary "can't do that to yourself" error. // scary "can't do that to yourself" error.
navigate('/friends'); navigate('/friends');
showToast(t('friends.selfInvite')); showToast(t('friends.selfInvite'));
return 'friendCode self_relation';
} else if (code === 'friend_code_invalid') { } else if (code === 'friend_code_invalid') {
// A previously shared link whose single-use, 12h code is already spent or expired: // A previously shared link whose single-use, 12h code is already spent or expired:
// land in the lobby and gently point at the bot, rather than a red error on Friends. // land in the lobby and gently point at the bot, rather than a red error on Friends.
navigate('/'); navigate('/');
app.staleInvite = true; app.staleInvite = true;
return 'friendCode invalid/stale -> lobby';
} else { } else {
navigate('/friends'); navigate('/friends');
handleError(err); handleError(err);
return 'friendCode err: ' + (code || 'unknown');
} }
} }
return;
default: default:
navigate('/'); navigate('/');
return 'lobby (empty/unknown param)';
} }
} }
+3 -3
View File
@@ -42,12 +42,12 @@ describe('vkShareLink', () => {
it('returns null outside a VK launch (no vk_app_id)', () => { it('returns null outside a VK launch (no vk_app_id)', () => {
vi.stubGlobal('location', { search: '' }); vi.stubGlobal('location', { search: '' });
expect(vkShareLink('f123456')).toBeNull(); expect(vkShareLink()).toBeNull();
}); });
it('wraps the payload in a vk.com/app link as the hash query param', () => { it('returns the plain vk.com/app link (VK drops a custom query payload, so the code is not carried)', () => {
vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' }); vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' });
expect(vkShareLink('f123456')).toBe('https://vk.com/app6736218?hash=f123456'); expect(vkShareLink()).toBe('https://vk.com/app6736218');
}); });
}); });
+7 -7
View File
@@ -78,14 +78,14 @@ export function shareLink(param: string): string | null {
} }
/** /**
* vkShareLink wraps a deep-link start parameter in a VK Mini App link * vkShareLink returns the VK Mini App link (https://vk.com/app<id>) to share on VK, or null when the
* (https://vk.com/app<id>?hash=<param>), read back by vk.ts vkStartParam (the `hash` query param). * VK app id is unknown (outside a VK launch). VK forwards no custom payload through the app link to
* VK's documented '#' direct-link form is consumed by the vk.com SPA before it reaches the app, so * the iframe — the '#' form is eaten by the vk.com SPA and a '?' query is dropped (confirmed on the
* we pass the payload as a query parameter instead. Returns null when the VK app id is unknown * contour: only the signed vk_* params arrive) — so the link cannot carry the friend code; the
* (outside a VK launch), so the caller falls back to the Telegram/web link. * recipient enters the copied code by hand.
*/ */
export function vkShareLink(param: string): string | null { export function vkShareLink(): string | null {
const id = vkAppId(); const id = vkAppId();
if (!id) return null; if (!id) return null;
return `https://vk.com/app${id}?hash=${encodeURIComponent(param)}`; return `https://vk.com/app${id}`;
} }
+1 -2
View File
@@ -201,8 +201,7 @@
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button> <button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div> </div>
{#if code} {#if code}
{@const sharePayload = friendCodeParam(code.code)} {@const shareUrl = insideVK() ? vkShareLink() : shareLink(friendCodeParam(code.code))}
{@const shareUrl = insideVK() ? vkShareLink(sharePayload) : shareLink(sharePayload)}
<div class="code" data-testid="friend-code"> <div class="code" data-testid="friend-code">
<div class="coderow"> <div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button> <button class="codeval" onclick={copyCode}>{code.code}</button>