Route cloud access through Caterium and vendor login SDK

This commit is contained in:
pavlov346346-source 2026-09-18 19:07:47 +03:00
parent ff7fe90ccf
commit 71bc56b0cd
18 changed files with 426 additions and 99 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Preserve the published SDK bytes, including whitespace inside template strings.
public/vendor/supabase-2.112.4.min.js -text -diff

View File

@ -16,5 +16,6 @@ jobs:
- run: npm ci
- run: npm audit --audit-level=high
- run: npm run check:deploy
- run: php -l public/api/index.php && php -l ops/timeweb/api-proxy.php
- run: npx playwright install --with-deps chromium webkit
- run: npm run test:e2e

View File

@ -11,7 +11,7 @@
"serverReady": true,
"workspaceAutoDiscovery": true,
"invitesTemporarilyDisabled": false,
"pwaCache": "v103-20260918-help-center",
"pwaCache": "v104-20260918-russia-proxy",
"fullOfferDescriptions": true,
"dynamicOfferRows": true,
"pdfOfferDescriptionFix": true,

View File

@ -1,5 +1,35 @@
# Timeweb fallback auto-deploy
## Backend access without external browser dependencies
The browser loads the pinned Supabase SDK from `public/vendor/`, then sends
Auth, REST, Storage and Functions traffic to the same-origin PHP entry point
`https://app.caterium.ru/api/index.php?__caterium_path=...`.
The query route is deliberate: nginx can serve static extensions before Apache,
so rewriting a storage URL ending in `.jpg` is insufficient on this hosting.
`public/api/index.php` must be identical to `ops/timeweb/api-proxy.php`.
The app copy is deployed with the release; the dedicated API host copy is
installed separately at `/home/c/ci503744/public_html/api-proxy/index.php`.
Only `app.caterium.ru` and `api.caterium.ru` are accepted, with a fixed Supabase
upstream and the existing allowlist of backend paths. Browser authorization is
forwarded unchanged; RLS remains enforced. Responses are private/no-store, and
the service worker never caches `/api/`.
Safe reads and password login may retry on `api.caterium.ru`. Direct Supabase
fallback is disabled. Writes and refresh-token exchanges are not replayed;
uncertain saves retain the existing read/revision recovery path. The Supabase
client still uses the project URL internally, preserving existing auth sessions.
Updates use HTTP: a revision-only request every 20 seconds while visible, and
chat refresh every 10 seconds. Unchanged bases are not downloaded again. Requests
from the previous account are discarded. Typing/online indicators require a
future WebSocket-capable proxy and are not advertised by the HTTP mode.
This covers application data and media. Optional map/geocoding providers remain
external and do not gate login or order loading. If the login page itself cannot
open, diagnose DNS/TLS/operator connectivity separately.
Use this only for the Year/shared-hosting fallback where system `crontab` is unavailable and scheduling is configured in the Timeweb panel.
## Production target: app.caterium.ru

View File

@ -1,23 +1,26 @@
<?php
/**
* api.caterium.ru -> Supabase managed backend reverse proxy.
* Caterium same-origin /api and api.caterium.ru -> Supabase HTTP proxy.
* Forwards only REST/Auth/Storage/Edge Functions HTTP traffic.
* Realtime/WebSocket is intentionally NOT proxied here (see ops/timeweb/README.md).
* Browser updates use authenticated HTTP polling; this is not a WebSocket proxy.
* Upstream host is a fixed constant - never derived from request input (no open-proxy risk).
*/
const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.supabase.co';
const PUBLIC_BASE = 'https://api.caterium.ru';
const SERVE_HOST = 'api.caterium.ru';
const ALLOWED_PREFIXES = ['/rest/v1/', '/auth/v1/', '/storage/v1/', '/functions/v1/'];
// Defense in depth: this script lives inside a document root shared with
// caterium.ru (2-site plan limit). The root .htaccess only rewrites into
// here for Host: api.caterium.ru, but a bare direct hit on this file's own
// path (under any host) must still refuse to act as a generic proxy.
// The same source is deployed to the dedicated API host and app /api/index.php.
// Never accept another Host or derive the upstream host from browser input.
$requestHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
if ($requestHost !== SERVE_HOST) {
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY);
if ($requestHost === 'app.caterium.ru' && $path === '/api/index.php' && is_string($_GET['__caterium_path'] ?? null)) {
$path = $_GET['__caterium_path'];
$query = implode('&', array_filter(explode('&', $query ?? ''), static function ($part) {
return urldecode(explode('=', $part, 2)[0]) !== '__caterium_path';
}));
} elseif ($requestHost !== 'api.caterium.ru') {
http_response_code(404);
exit;
}
@ -34,7 +37,7 @@ const FORWARD_REQUEST_HEADERS = [
];
const STRIP_RESPONSE_HEADERS = [
'transfer-encoding', 'connection', 'content-encoding', 'content-length',
'transfer-encoding', 'connection', 'content-encoding', 'content-length', 'cache-control', 'expires', 'pragma', 'set-cookie',
];
function send_cors_headers(): void
@ -72,15 +75,14 @@ function request_headers(): array
}
send_cors_headers();
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY);
$allowed = false;
foreach (ALLOWED_PREFIXES as $prefix) {
if (strpos($path, $prefix) === 0) {
@ -89,7 +91,7 @@ foreach (ALLOWED_PREFIXES as $prefix) {
}
}
if (!$allowed) {
if (!$allowed || strpos(rawurldecode($path), '..') !== false || preg_match('/[\\\\?#\x00-\x20]/', $path)) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']);
@ -125,6 +127,7 @@ curl_setopt_array($ch, [
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_NOBODY => $method === 'HEAD',
]);
if ($body !== null && $body !== '') {
@ -165,6 +168,9 @@ foreach (preg_split('/\r\n/', $rawHeaders) as $line) {
if (in_array($headerName, STRIP_RESPONSE_HEADERS, true) || strpos($headerName, 'access-control-') === 0) {
continue;
}
// Backend HTTP endpoints do not require external redirects. Keep any
// upstream redirect on the dedicated API host, including signed downloads.
if ($headerName === 'location') $line = str_replace(UPSTREAM, 'https://api.caterium.ru', $line);
header($line, false);
}
@ -173,7 +179,7 @@ foreach (preg_split('/\r\n/', $rawHeaders) as $line) {
// request (an <img src>, a download link, ...) is proxied too, not sent to
// *.supabase.co directly. Only touch text/JSON bodies - never binary payloads.
if (stripos($responseContentType, 'application/json') !== false || stripos($responseContentType, 'text/') !== false) {
$respBody = str_replace(UPSTREAM, PUBLIC_BASE, $respBody);
$respBody = str_replace(UPSTREAM, 'https://api.caterium.ru', $respBody);
}
echo $respBody;

185
public/api/index.php Normal file
View File

@ -0,0 +1,185 @@
<?php
/**
* Caterium same-origin /api and api.caterium.ru -> Supabase HTTP proxy.
* Forwards only REST/Auth/Storage/Edge Functions HTTP traffic.
* Browser updates use authenticated HTTP polling; this is not a WebSocket proxy.
* Upstream host is a fixed constant - never derived from request input (no open-proxy risk).
*/
const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.supabase.co';
const ALLOWED_PREFIXES = ['/rest/v1/', '/auth/v1/', '/storage/v1/', '/functions/v1/'];
// The same source is deployed to the dedicated API host and app /api/index.php.
// Never accept another Host or derive the upstream host from browser input.
$requestHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY);
if ($requestHost === 'app.caterium.ru' && $path === '/api/index.php' && is_string($_GET['__caterium_path'] ?? null)) {
$path = $_GET['__caterium_path'];
$query = implode('&', array_filter(explode('&', $query ?? ''), static function ($part) {
return urldecode(explode('=', $part, 2)[0]) !== '__caterium_path';
}));
} elseif ($requestHost !== 'api.caterium.ru') {
http_response_code(404);
exit;
}
const ALLOWED_ORIGINS = [
'https://app.caterium.ru',
'https://caterium.ru',
'https://www.caterium.ru',
];
const FORWARD_REQUEST_HEADERS = [
'authorization', 'apikey', 'content-type', 'prefer', 'range',
'x-client-info', 'x-supabase-api-version', 'accept-profile', 'content-profile', 'x-upsert', 'cache-control',
];
const STRIP_RESPONSE_HEADERS = [
'transfer-encoding', 'connection', 'content-encoding', 'content-length', 'cache-control', 'expires', 'pragma', 'set-cookie',
];
function send_cors_headers(): void
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, ALLOWED_ORIGINS, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
}
header('Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: ' . implode(', ', FORWARD_REQUEST_HEADERS));
header('Access-Control-Max-Age: 86400');
}
function request_headers(): array
{
if (function_exists('getallheaders')) {
$raw = getallheaders();
if (is_array($raw)) {
return $raw;
}
}
// Fallback for environments without getallheaders().
$out = [];
foreach ($_SERVER as $key => $value) {
if (strpos($key, 'HTTP_') === 0) {
$name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5)))));
$out[$name] = $value;
}
}
if (isset($_SERVER['CONTENT_TYPE'])) {
$out['Content-Type'] = $_SERVER['CONTENT_TYPE'];
}
return $out;
}
send_cors_headers();
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
$allowed = false;
foreach (ALLOWED_PREFIXES as $prefix) {
if (strpos($path, $prefix) === 0) {
$allowed = true;
break;
}
}
if (!$allowed || strpos(rawurldecode($path), '..') !== false || preg_match('/[\\\\?#\x00-\x20]/', $path)) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']);
exit;
}
$upstreamUrl = UPSTREAM . $path . ($query !== null && $query !== '' ? '?' . $query : '');
$incoming = request_headers();
$incomingLower = [];
foreach ($incoming as $name => $value) {
$incomingLower[strtolower($name)] = $value;
}
$forwardHeaders = [];
foreach (FORWARD_REQUEST_HEADERS as $name) {
if (isset($incomingLower[$name]) && $incomingLower[$name] !== '') {
$forwardHeaders[] = $name . ': ' . $incomingLower[$name];
}
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$body = ($method === 'GET' || $method === 'HEAD') ? null : file_get_contents('php://input');
$ch = curl_init($upstreamUrl);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $forwardHeaders,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_NOBODY => $method === 'HEAD',
]);
if ($body !== null && $body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
if ($response === false) {
http_response_code(502);
header('Content-Type: application/json');
echo json_encode(['error' => 'upstream_unreachable']);
curl_close($ch);
exit;
}
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$rawHeaders = substr($response, 0, $headerSize);
$respBody = substr($response, $headerSize);
curl_close($ch);
http_response_code($statusCode);
$responseContentType = '';
foreach (preg_split('/\r\n/', $rawHeaders) as $line) {
if ($line === '' || stripos($line, 'HTTP/') === 0) {
continue;
}
$colon = strpos($line, ':');
if ($colon === false) {
continue;
}
$headerName = strtolower(trim(substr($line, 0, $colon)));
if ($headerName === 'content-type') {
$responseContentType = trim(substr($line, $colon + 1));
}
if (in_array($headerName, STRIP_RESPONSE_HEADERS, true) || strpos($headerName, 'access-control-') === 0) {
continue;
}
// Backend HTTP endpoints do not require external redirects. Keep any
// upstream redirect on the dedicated API host, including signed downloads.
if ($headerName === 'location') $line = str_replace(UPSTREAM, 'https://api.caterium.ru', $line);
header($line, false);
}
// Supabase returns absolute upstream URLs inside some JSON bodies (e.g. Storage
// createSignedUrl). Rewrite those to our public host so the browser's follow-up
// request (an <img src>, a download link, ...) is proxied too, not sent to
// *.supabase.co directly. Only touch text/JSON bodies - never binary payloads.
if (stripos($responseContentType, 'application/json') !== false || stripos($responseContentType, 'text/') !== false) {
$respBody = str_replace(UPSTREAM, 'https://api.caterium.ru', $respBody);
}
echo $respBody;

View File

@ -2158,7 +2158,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
const DB_NAME = 'SunCloudV2';
const DB_VERSION = 1;
const BUCKET = 'sun-media';
const SUPABASE_JS = 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.112.4';
const SUPABASE_JS = '/vendor/supabase-2.112.4.min.js';
const MEDIA_PREFIX = '__sun_media__:';
const SYNC_DEBOUNCE = 1800;
@ -2175,7 +2175,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
const DEFAULT_SUPABASE_KEY = 'sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy';
const SUPABASE_API_PROXY = 'https://api.caterium.ru';
const PROXY_FETCH_TIMEOUT_MS = 12000;
const supabaseProxyFetch=window.CateriumCloudTransport.create({upstream:DEFAULT_SUPABASE_URL,proxy:SUPABASE_API_PROXY,timeout:PROXY_FETCH_TIMEOUT_MS});
const supabaseProxyFetch=window.CateriumCloudTransport.create({upstream:DEFAULT_SUPABASE_URL,proxy:location.origin+'/api/index.php',fallbackProxy:SUPABASE_API_PROXY,timeout:PROXY_FETCH_TIMEOUT_MS});
let config = loadConfig();
let client = null;
@ -2189,7 +2189,8 @@ window.SUN_LEGACY_CATALOG_V175=[];
let membershipsLoaded = false;
let membershipError = '';
let membershipLoad = null;
let realtimeChannel = null;
let remotePollTimer = null;
let remotePollBusy = false;
let syncTimer = null;
let networkRetryTimer = null;
let isSyncing = false;
@ -3353,14 +3354,27 @@ window.SUN_LEGACY_CATALOG_V175=[];
catch(error){handleError(error,'Не удалось создать приглашение.');}
}
function unsubscribeRealtime(){if(realtimeChannel&&client){try{client.removeChannel(realtimeChannel);}catch(_){}}realtimeChannel=null;}
function unsubscribeRealtime(){clearInterval(remotePollTimer);remotePollTimer=null;}
async function pollRemoteChanges(){
if(remotePollBusy||document.hidden||!navigator.onLine||isSyncing||supportMode||!workspaceMigrated()||!session?.user||!workspace?.id)return;
const startedUser=session.user.id,startedWorkspace=workspace.id,startedClient=client;
const current=()=>!signOutInProgress&&client===startedClient&&session?.user?.id===startedUser&&workspace?.id===startedWorkspace;
remotePollBusy=true;
try{
// Poll only the revision; download/merge the full base only after a change.
// The same RLS and authorization apply as to the previous socket channel.
const {data,error}=await client.from('sun_app_state').select('revision').eq('workspace_id',startedWorkspace).maybeSingle();
if(error)throw error;
const baseline=await getBaseline();
if(current()&&data&&Number(data.revision)!==Number(baseline?.revision))await syncNow({quiet:true});
}catch(_){/* A later poll retries; normal saves retain their visible error/retry UI. */}
finally{remotePollBusy=false;}
}
function subscribeRealtime(){
unsubscribeRealtime();if(!client||!session||!workspace?.id)return;
realtimeChannel=client.channel(`sun-state-${workspace.id}-${clientId()}`).on('postgres_changes',{event:'INSERT',schema:'public',table:'sun_sync_events',filter:`workspace_id=eq.${workspace.id}`},async payload=>{
if(payload?.new?.client_id===clientId())return;
try{const row=await fetchRemoteRow();if(row)await handleRealtimeRow(row);}catch(error){handleError(error,'Не удалось получить изменения с другого устройства.');}
}).subscribe(status=>{if(status==='CHANNEL_ERROR')setStatus('error','Realtime не подключился. Обычная синхронизация продолжит работать.');});
remotePollTimer=setInterval(pollRemoteChanges,20000);
}
document.addEventListener('visibilitychange',()=>{if(remotePollTimer&&!document.hidden)pollRemoteChanges();});
function handleError(error, message) {
if(signOutInProgress)return;
@ -5513,15 +5527,12 @@ window.SUN_LEGACY_CATALOG_V175=[];
let messages = [];
let activeThread = null;
let workspaceChannel = null;
let threadChannel = null;
let workspaceChannelId = '';
let threadChannelId = '';
let online = new Set();
let pendingFiles = [];
let loadingThreads = false;
let loadingMessages = false;
let typingStopTimer = null;
let lastTypingSent = 0;
let typingUsers = new Map();
let sidebarMode = 'general';
let chatDock = 'global';
@ -5643,7 +5654,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
function activeSubtitle(t){
if(!t)return '';
if(t.kind==='direct'){const m=memberById(t.other_user_id);return online.has(String(t.other_user_id))?'<span class="sun-chat-online-text">онлайн</span>':esc(ROLE_LABELS[m?.role]||'сотрудник');}
if(t.kind==='company'){const count=members.length,on=members.filter(m=>online.has(String(m.user_id))).length;return `${count} сотрудников · ${on} онлайн`;}
if(t.kind==='company')return `${members.length} сотрудников`;
return 'Обсуждение заказа';
}
@ -5676,7 +5687,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
async function hydrateAttachments(){
const c=client();if(!c)return;
const links=qa('[data-chat-attachment]',$('sunChatBodyV29'));
for(const a of links){const path=a.dataset.chatAttachment;if(!path)continue;try{const r=await c.storage.from('sun-chat').createSignedUrl(path,3600);if(r.error)throw r.error;const url=r.data?.signedUrl;if(!url)continue;a.href=url;const type=a.dataset.chatType||'';if(type.startsWith('image/')){const p=a.querySelector('.sun-chat-file-preview');if(p)p.innerHTML=`<img class="sun-chat-file-img" alt="Вложение" src="${esc(url)}">`;}}catch(e){a.onclick=ev=>{ev.preventDefault();toast('Не удалось открыть вложение.','error')}}}
for(const a of links){const path=a.dataset.chatAttachment;if(!path)continue;try{const r=await c.storage.from('sun-chat').createSignedUrl(path,3600);if(r.error)throw r.error;const url=window.CateriumCloudTransport.mediaUrl(r.data?.signedUrl);if(!url)continue;a.href=url;const type=a.dataset.chatType||'';if(type.startsWith('image/')){const p=a.querySelector('.sun-chat-file-preview');if(p)p.innerHTML=`<img class="sun-chat-file-img" alt="Вложение" src="${esc(url)}">`;}}catch(e){a.onclick=ev=>{ev.preventDefault();toast('Не удалось открыть вложение.','error')}}}
}
function renderTyping(){const root=$('sunChatTypingV29');if(!root)return;const names=[...typingUsers.values()].filter(x=>x&&x.user_id!==me()&&Date.now()-x.at<3500).map(x=>x.name||'Сотрудник');root.textContent=names.length?`${names.slice(0,2).join(', ')} ${names.length>1?'печатают':'печатает'}`:'';}
@ -5685,10 +5696,14 @@ window.SUN_LEGACY_CATALOG_V175=[];
async function loadThreads({quiet=false}={}){
if(!available()||loadingThreads)return;
const startedWorkspace=workspace().id,startedUser=me();
const current=()=>available()&&workspace().id===startedWorkspace&&me()===startedUser;
loadingThreads=true;
try{
const ws=workspace();await rpc('sun_chat_get_company_thread_v29',{p_workspace:ws.id});
if(!current())return;
const [t,m]=await Promise.all([rpc('sun_chat_list_threads_v29',{p_workspace:ws.id}),rpc('sun_chat_list_members_v29',{p_workspace:ws.id})]);
if(!current())return;
threads=Array.isArray(t)?t:[];members=Array.isArray(m)?m:[];
if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh)activeThread={...activeThread,...fresh};}
renderSidebar();renderHead();updateUnreadBadges();
@ -5697,11 +5712,16 @@ window.SUN_LEGACY_CATALOG_V175=[];
async function loadMessages({mark=true,quiet=false}={}){
if(!activeThread||!available()||loadingMessages)return;
loadingMessages=true;renderMessages();
const startedThread=activeThread.thread_id,startedWorkspace=workspace().id,startedUser=me();
const current=()=>available()&&workspace().id===startedWorkspace&&me()===startedUser&&activeThread?.thread_id===startedThread;
let changed=!quiet;
loadingMessages=true;if(!quiet)renderMessages();
try{
const data=await rpc('sun_chat_list_messages_v29',{p_thread:activeThread.thread_id,p_limit:120,p_before:null});messages=Array.isArray(data)?data:[];
const data=await rpc('sun_chat_list_messages_v29',{p_thread:startedThread,p_limit:120,p_before:null});
if(!current())return;
const next=Array.isArray(data)?data:[];changed=changed||JSON.stringify(next)!==JSON.stringify(messages);messages=next;
if(mark){const t=threadById(activeThread.thread_id);if(Number(t?.unread_count||0)>0){await rpc('sun_chat_mark_read_v29',{p_thread:activeThread.thread_id});await loadThreads({quiet:true});}}
}catch(e){if(!quiet)toast(e.message||String(e),'error',6500)}finally{loadingMessages=false;renderMessages();}
}catch(e){if(!quiet&&current())toast(e.message||String(e),'error',6500)}finally{loadingMessages=false;if(current()&&changed)renderMessages();}
}
async function openThread(t){
@ -5776,34 +5796,26 @@ window.SUN_LEGACY_CATALOG_V175=[];
}
async function ensureWorkspaceChannel(){
if(!available())return unsubscribeWorkspace();const c=client(),ws=workspace(),ss=session();if(!c||!ws||!ss)return;
if(workspaceChannel&&workspaceChannelId===ws.id)return;
unsubscribeWorkspace();workspaceChannelId=ws.id;
try{if(c.realtime?.setAuth)await c.realtime.setAuth(ss.access_token);}catch(_){}
const ch=c.channel(`sun-chat-workspace:${ws.id}`,{config:{private:true,presence:{key:me()},broadcast:{ack:true}}});workspaceChannel=ch;
ch.on('broadcast',{event:'chat_changed'},async()=>{await loadThreads({quiet:true});if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh&&Number(fresh.unread_count||0)>0)await loadMessages({mark:true,quiet:true});else renderMessages();}})
.on('presence',{event:'sync'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();})
.on('presence',{event:'join'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();})
.on('presence',{event:'leave'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();})
.subscribe(async(status)=>{if(status==='SUBSCRIBED'){try{await ch.track({user_id:me(),name:meName(),at:new Date().toISOString()})}catch(_){}await loadThreads({quiet:true});}});
if(!available())return unsubscribeWorkspace();
const identity=workspace().id+':'+me();
if(workspaceChannel&&workspaceChannelId===identity)return;
if(workspaceChannelId&&workspaceChannelId!==identity){activeThread=null;threads=[];members=[];messages=[];pendingFiles=[];renderMessages();renderPending();}
unsubscribeWorkspace();workspaceChannelId=identity;
workspaceChannel=setInterval(async()=>{
if(document.hidden||!navigator.onLine||!available()||workspaceChannelId!==identity)return;
await loadThreads({quiet:true});
if(workspaceChannelId!==identity)return;
const main=$('sunChatMainV29');
if(activeThread&&main?.getClientRects().length)await loadMessages({mark:true,quiet:true});
},10000);
}
function unsubscribeWorkspace(){if(workspaceChannel){try{client()?.removeChannel?.(workspaceChannel)}catch(_){}workspaceChannel=null;}workspaceChannelId='';online=new Set();unsubscribeThread();}
function unsubscribeWorkspace(){clearInterval(workspaceChannel);workspaceChannel=null;workspaceChannelId='';online=new Set();unsubscribeThread();}
async function subscribeThread(id){
if(!available()||!id)return;const c=client(),ss=session();if(threadChannel&&threadChannelId===id)return;unsubscribeThread();threadChannelId=id;
try{if(c.realtime?.setAuth)await c.realtime.setAuth(ss.access_token);}catch(_){}
const ch=c.channel(`sun-chat-thread:${id}`,{config:{private:true,broadcast:{ack:false,self:false}}});threadChannel=ch;
ch.on('broadcast',{event:'message'},()=>loadMessages({mark:true,quiet:true}))
.on('broadcast',{event:'read'},()=>loadMessages({mark:false,quiet:true}))
.on('broadcast',{event:'typing'},payload=>{const p=payload?.payload||{};if(!p.user_id||String(p.user_id)===String(me()))return;if(p.typing)typingUsers.set(String(p.user_id),{user_id:p.user_id,name:p.name||'Сотрудник',at:Date.now()});else typingUsers.delete(String(p.user_id));renderTyping();setTimeout(()=>renderTyping(),3600)})
.subscribe();
}
function unsubscribeThread(){if(threadChannel){try{client()?.removeChannel?.(threadChannel)}catch(_){}threadChannel=null;}threadChannelId='';typingUsers.clear();renderTyping();}
async function sendTyping(flag){
if(!threadChannel||!activeThread)return;const now=Date.now();if(flag&&now-lastTypingSent<700){clearTimeout(typingStopTimer);typingStopTimer=setTimeout(()=>sendTyping(false),1800);return}lastTypingSent=now;
try{await threadChannel.send({type:'broadcast',event:'typing',payload:{user_id:me(),name:meName(),typing:Boolean(flag)}})}catch(_){}
clearTimeout(typingStopTimer);if(flag)typingStopTimer=setTimeout(()=>sendTyping(false),1800);
if(!available()||!id)return;unsubscribeThread();threadChannelId=id;
}
function unsubscribeThread(){threadChannelId='';typingUsers.clear();renderTyping();}
function sendTyping(){/* Typing/presence require a WebSocket-capable proxy. */}
function updateOrderChatBadge(){
const b=$('sunOrderChatTabV31');if(!b)return;const id=currentOrderId(),t=id?orderThreadByOrder(id):null,n=Number(t?.unread_count||0);let badge=b.querySelector('.sun-chat-mini-badge');if(n>0){if(!badge){badge=document.createElement('span');badge.className='sun-chat-mini-badge';b.appendChild(badge);}badge.textContent=n>99?'99+':String(n);}else badge?.remove();
@ -5851,7 +5863,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(safeStateChanged,60));
window.addEventListener('sun:cloud-state-applied',()=>setTimeout(safeStateChanged,60));
window.addEventListener('online',()=>{if(available())ensureWorkspaceChannel()});
window.addEventListener('beforeunload',()=>{try{workspaceChannel?.untrack?.()}catch(_){}});
window.addEventListener('beforeunload',unsubscribeWorkspace);
let tries=0;bootRetryTimer=setInterval(()=>{tries++;safeStateChanged();if(available()||tries>=24){clearInterval(bootRetryTimer);bootRetryTimer=null;}},750);setTimeout(safeStateChanged,250);
}

View File

@ -3,9 +3,21 @@
const READ_RPCS=new Set(['sun_my_workspaces','sun_fetch_app_state','sun_is_platform_admin','caterium_trial_demo_status']);
const isTransient=error=>/TimeoutError|AbortError|CATERIUM_TIMEOUT|Failed to fetch|fetch failed|NetworkError|Load failed|network request failed|превышено время ожидания/i.test(String(error?.message||error||''));
const errorMessage=error=>isTransient(error)?'Сервер временно не отвечает. Изменения остаются на этом устройстве. Проверьте соединение и повторите загрузку.':String(error?.message||error||'Неизвестная ошибка');
function create({upstream,proxy,timeout=12000,fallbackTimeout=15000,writeTimeout=35000,cooldown=60000}){
function routeUrl(base,url){
// Explicit PHP entry point also works for storage paths ending in .jpg:
// Timeweb serves static extensions before Apache rewrite rules.
return base.endsWith('.php')?base+'?__caterium_path='+encodeURIComponent(url.pathname)+(url.search?'&'+url.search.slice(1):''):base+url.pathname+url.search;
}
function mediaUrl(value){
if(!value)return value;
const url=new URL(value,location.href);
if(['https://usfjwhztqoopzzfmfbis.supabase.co','https://api.caterium.ru'].includes(url.origin)&&url.pathname.startsWith('/storage/v1/'))return routeUrl(location.origin+'/api/index.php',url);
return value;
}
function create({upstream,proxy,fallbackProxy=proxy,timeout=12000,fallbackTimeout=15000,writeTimeout=35000,cooldown=60000}){
const origin=new URL(upstream).origin;
let directUntil=0;
proxy=proxy.replace(/\/$/,'');fallbackProxy=fallbackProxy.replace(/\/$/,'');
let fallbackUntil=0;
return async function(input,init={}){
const original=new Request(input,init),url=new URL(original.url),isBackend=url.origin===origin;
// A Request used as RequestInit exposes its ReadableStream body. Safari
@ -14,7 +26,7 @@
const requestInit={method:original.method,headers:original.headers,body,credentials:original.credentials,mode:original.mode,cache:original.cache,redirect:original.redirect,referrer:original.referrer,referrerPolicy:original.referrerPolicy,integrity:original.integrity,keepalive:original.keepalive};
const read=original.method==='GET'||original.method==='HEAD'||(original.method==='POST'&&url.pathname.startsWith('/rest/v1/rpc/')&&READ_RPCS.has(url.pathname.slice('/rest/v1/rpc/'.length)));
const passwordLogin=original.method==='POST'&&url.pathname==='/auth/v1/token'&&url.searchParams.get('grant_type')==='password';
const safeFallback=isBackend&&(read||passwordLogin);
const safeFallback=isBackend&&proxy!==fallbackProxy&&(read||passwordLogin);
const expectJson=original.method!=='HEAD'&&(passwordLogin||url.pathname.startsWith('/rest/v1/rpc/')||(read&&(url.pathname.startsWith('/rest/v1/')||url.pathname.startsWith('/auth/v1/'))));
async function attempt(target,limit){
const controller=new AbortController(),abort=()=>controller.abort(original.signal.reason);
@ -31,18 +43,18 @@
return response;
}finally{clearTimeout(timer);original.signal.removeEventListener('abort',abort)}
}
const directFirst=isBackend&&Date.now()<directUntil;
const first=directFirst?original.url:isBackend?proxy+url.pathname+url.search:original.url;
const second=directFirst?proxy+url.pathname+url.search:original.url;
const fallbackFirst=isBackend&&Date.now()<fallbackUntil;
const first=isBackend?routeUrl(fallbackFirst?fallbackProxy:proxy,url):original.url;
const second=routeUrl(fallbackFirst?proxy:fallbackProxy,url);
try{
const response=await attempt(first,safeFallback?(directFirst?fallbackTimeout:timeout):writeTimeout);
const response=await attempt(first,safeFallback?(fallbackFirst?fallbackTimeout:timeout):writeTimeout);
if(!safeFallback||response.status<500)return response;
}catch(error){if(!safeFallback||original.signal.aborted)throw error}
// Same backend, same authorization, one SDK session. Never replay writes.
const response=await attempt(second,directFirst?timeout:fallbackTimeout);
if(response.ok)directUntil=directFirst?0:Date.now()+cooldown;
const response=await attempt(second,fallbackFirst?timeout:fallbackTimeout);
if(response.ok)fallbackUntil=fallbackFirst?0:Date.now()+cooldown;
return response;
};
}
window.CateriumCloudTransport=Object.freeze({create,isTransient,errorMessage});
window.CateriumCloudTransport=Object.freeze({create,isTransient,errorMessage,mediaUrl});
})();

View File

@ -1,7 +1,7 @@
(()=>{
'use strict';
const VERSION='17.7.3';
const RELEASE='20260918-help-center';
const RELEASE='20260918-russia-proxy';
const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}};
function installAuthBoot(){

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,7 @@
const CACHE='sun-catering-pwa-v103-20260918-help-center';
const VERSION='20260918-help-center';
const CACHE='sun-catering-pwa-v104-20260918-russia-proxy';
const VERSION='20260918-russia-proxy';
const CORE=[
'./vendor/supabase-2.112.4.min.js',
`./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`,
'./','./index.html',`./core/mobile-order.js?v=${VERSION}`,`./core/proposal-layout.js?v=${VERSION}`,'./fonts/Manrope.ttf','./fonts/PlayfairDisplay.ttf','./fonts/PlayfairDisplay-Italic.ttf',`./core/trial-demo.js?v=${VERSION}`,`./core/cloud-transport.js?v=${VERSION}`,`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`,
`./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/login-signature-v1776.css?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`,

12
public/vendor/README.md vendored Normal file
View File

@ -0,0 +1,12 @@
# Supabase browser SDK
`supabase-2.112.4.min.js` is the unmodified `dist/umd/supabase.js` from
the official npm package `@supabase/supabase-js@2.112.4`.
Source: https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.112.4.tgz
SHA-256: `f8ce7fab799af1916019cbd0b485b39bb80dbdbc6dc062909a751c9e5198e04c`
MIT license: `supabase-LICENSE.txt`, from the upstream Supabase repository.
Hosting the SDK here keeps login independent of external CDNs. Keep this
version aligned with the runtime loader, PWA assets and browser tests.

BIN
public/vendor/supabase-2.112.4.min.js vendored Normal file

Binary file not shown.

21
public/vendor/supabase-LICENSE.txt vendored Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Supabase
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,6 +1,7 @@
import fs from 'node:fs';
import vm from 'node:vm';
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
const source=fs.readFileSync('public/app-runtime.js','utf8');
const code=source.slice(source.indexOf(' function loadConfig() {'),source.indexOf(' function saveConfig() {'));
const url='https://usfjwhztqoopzzfmfbis.supabase.co',key='sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy';
@ -8,4 +9,6 @@ const run=config=>{const storage=new Map([['sunCloudV2Config',JSON.stringify(con
for(const retired of ['https://cksuehzcimitsxmeloes.supabase.co','https://api.caterium.ru']){const {result,storage}=run({url:retired,key:'old-key',workspaceId:'old-company',autoSync:false});assert.equal(result.url,url);assert.equal(result.key,key);assert.equal(result.workspaceId,'');assert.equal(result.legacyLocalWorkspaceId,'old-company');assert.equal(result.autoSync,false);assert.equal(storage.get('sunOrders'),'[{"id":"old-order"}]');assert.ok(storage.has('cateriumRetiredBackend20260917'));}
const {result}=run({url,key,workspaceId:'new-company',localWorkspaceId:'new-company',tenantStorageReady:true});assert.equal(result.workspaceId,'new-company');assert.equal(result.localWorkspaceId,'new-company');
assert.equal(run({}).result.url,url);
assert.equal(fs.readFileSync('public/api/index.php','utf8').replaceAll('\r\n','\n'),fs.readFileSync('ops/timeweb/api-proxy.php','utf8').replaceAll('\r\n','\n'),'both deployed proxy entry points use the same reviewed source');
assert.equal(createHash('sha256').update(fs.readFileSync('public/vendor/supabase-2.112.4.min.js','utf8').replaceAll('\r\n','\n')).digest('hex'),'f8ce7fab799af1916019cbd0b485b39bb80dbdbc6dc062909a751c9e5198e04c','vendored SDK matches the published package');
console.log('PASS backend cutover: retired config upgraded, old local data preserved separately, fresh config stable');

View File

@ -1,6 +1,42 @@
import fs from 'node:fs';
import {test,expect} from '@playwright/test';
test('same-origin proxy preserves encoded storage paths, tokens, filters and never falls back to Supabase',async({page})=>{
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<html><body></body></html>'}));
await page.goto('/index.html');await page.addScriptTag({url:'/core/cloud-transport.js'});
const result=await page.evaluate(async()=>{
const calls=[],upstream='https://usfjwhztqoopzzfmfbis.supabase.co',proxy=location.origin+'/api/index.php';
const send=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://api.caterium.ru'});
window.fetch=async request=>{calls.push(request.url);return new Response('unavailable',{status:503})};
await send(upstream+'/rest/v1/sun_app_state?select=revision&workspace_id=eq.company');
const path='/storage/v1/object/sign/sun-chat/company/photo%20one.jpg',query='?token=a%2Bb%2Fc%3D&download=photo.jpg';
const media=CateriumCloudTransport.mediaUrl(upstream+path+query),url=new URL(media);
return {calls,media,pathname:url.pathname,path:url.searchParams.get('__caterium_path'),token:url.searchParams.get('token'),download:url.searchParams.get('download'),external:CateriumCloudTransport.mediaUrl('https://example.invalid/image.jpg')};
});
expect(result.calls).toHaveLength(2);expect(result.calls.every(u=>!u.includes('.supabase.co'))).toBe(true);
expect(new URL(result.calls[0]).searchParams.get('workspace_id')).toBe('eq.company');
expect(result.pathname).toBe('/api/index.php');expect(result.path).toBe('/storage/v1/object/sign/sun-chat/company/photo%20one.jpg');expect(result.token).toBe('a+b/c=');expect(result.download).toBe('photo.jpg');
expect(result.external).toBe('https://example.invalid/image.jpg');
});
test('revision polling reads only changed bases and ignores the previous account response',async({page})=>{
await syncHarness(page);
const result=await page.evaluate(async()=>{
let revision=1,readCount=0,resolveRevision;const calls=[];
const payload={format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:[]}}};
const query={select(fields){calls.push(fields);return this},eq(key,value){calls.push([key,value]);return this},async maybeSingle(){readCount++;return revision===99?new Promise(r=>resolveRevision=r):{data:{revision}}}};
const c={from(table){calls.push(table);return query},async rpc(name){calls.push(name);return {data:[{revision,payload}]}}};
SunCloudV2.testInit(c);await SunCloudV2.testBaseline({revision:1,payload});
await SunCloudV2.testPoll();const unchanged=calls.splice(0);
revision=2;await SunCloudV2.testPoll();const changed=calls.splice(0);
revision=99;const pending=SunCloudV2.testPoll();await SunCloudV2.testPoll();SunCloudV2.testLeave();resolveRevision({data:{revision:99}});await pending;
return {unchanged,changed,late:calls,readCount};
});
expect(result.unchanged).toEqual(['sun_app_state','revision',['workspace_id','company']]);
expect(result.changed).toContain('sun_fetch_app_state');
expect(result.late).not.toContain('sun_fetch_app_state');expect(result.readCount).toBe(3);
});
test('Safari without streaming uploads can send login and binary bodies through fallback',async({page})=>{
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<html><body></body></html>'}));
await page.goto('/index.html');await page.addScriptTag({url:'/core/cloud-transport.js'});
@ -9,7 +45,7 @@ test('Safari without streaming uploads can send login and binary bodies through
window.Request=new Proxy(NativeRequest,{construct(Target,args){if(args[1]?.body instanceof ReadableStream)throw new TypeError('ReadableStream uploading is not supported');return new Target(...args)}});
const calls=[];
window.fetch=async request=>{calls.push({url:request.url,body:[...new Uint8Array(await request.arrayBuffer())],type:request.headers.get('content-type')});return request.url.includes('proxy.example')?new Response('',{status:503}):new Response('{}',{headers:{'content-type':'application/json'}})};
const send=CateriumCloudTransport.create({upstream:'https://backend.example',proxy:'https://proxy.example',cooldown:0});
const send=CateriumCloudTransport.create({upstream:'https://backend.example',proxy:'https://proxy.example',fallbackProxy:'https://secondary.example',cooldown:0});
const body=JSON.stringify({email:'test@example.invalid',password:'test-password'});
await send('https://backend.example/auth/v1/token?grant_type=password',{method:'POST',headers:{'content-type':'application/json'},body});
await send('https://backend.example/storage/v1/object/test/image',{method:'POST',headers:{'content-type':'image/png'},body:new Uint8Array([0,255,137,80]).buffer});
@ -69,20 +105,20 @@ test('slow connections use a healthy route, allow longer saves and preserve call
const upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid',calls=[];
const ok=()=>new Response('{}',{headers:{'content-type':'application/json'}});
window.fetch=(request,{signal})=>{calls.push(request.url);return request.url.startsWith(proxy)?new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true})):Promise.resolve(ok())};
const send=CateriumCloudTransport.create({upstream,proxy,timeout:10,fallbackTimeout:100,writeTimeout:100});
const send=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',timeout:10,fallbackTimeout:100,writeTimeout:100});
await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}'});
await send(upstream+'/rest/v1/rpc/sun_fetch_app_state',{method:'POST',body:'{}'});
const routes=calls.splice(0);
window.fetch=(request,{signal})=>{calls.push(request.url);return new Promise((resolve,reject)=>{const timer=setTimeout(()=>resolve(ok()),35);signal.addEventListener('abort',()=>{clearTimeout(timer);reject(signal.reason)},{once:true})})};
const slowSave=CateriumCloudTransport.create({upstream,proxy,timeout:10,writeTimeout:100});
const slowSave=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',timeout:10,writeTimeout:100});
const saved=(await slowSave(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})).status;const saveCalls=calls.splice(0);
const timeoutSend=CateriumCloudTransport.create({upstream,proxy,writeTimeout:5});let timeoutName='';
const timeoutSend=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',writeTimeout:5});let timeoutName='';
try{await timeoutSend(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})}catch(e){timeoutName=e.name}const timeoutCalls=calls.splice(0);
const controller=new AbortController();controller.abort(new DOMException('Account changed','AbortError'));let cancel='';
try{await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}',signal:controller.signal})}catch(e){cancel=e.message}
return {routes,saved,saveCalls,timeoutName,timeoutCalls,cancel,cancelCalls:calls};
});
expect(result.routes.map(u=>new URL(u).host)).toEqual(['proxy.example.invalid','backend.example.invalid','backend.example.invalid']);
expect(result.routes.map(u=>new URL(u).host)).toEqual(['proxy.example.invalid','secondary.example.invalid','secondary.example.invalid']);
expect(result.saved).toBe(200);expect(result.saveCalls).toHaveLength(1);expect(result.timeoutName).toBe('TimeoutError');expect(result.timeoutCalls).toHaveLength(1);
expect(result.cancel).toBe('Account changed');expect(result.cancelCalls).toHaveLength(0);
});
@ -91,7 +127,7 @@ async function syncHarness(page){
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<html><body></body></html>'}));await page.goto('/index.html');
await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'});
const runtime=fs.readFileSync('public/app-runtime.js','utf8');let source=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js'));
source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={testUploadDataUrl:uploadDataUrl,testInit:c=>{client=c;session={user:{id:'test-user'}};workspace={id:'company',role:'admin'};config.migrated.company=true;config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'},testBaseline:setBaseline,testLeave:()=>{session=null;workspace=null},`);
source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={testPoll:pollRemoteChanges,testUploadDataUrl:uploadDataUrl,testInit:c=>{client=c;session={user:{id:'test-user'}};workspace={id:'company',role:'admin'};config.migrated.company=true;config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'},testBaseline:setBaseline,testLeave:()=>{session=null;workspace=null},`);
await page.addScriptTag({content:'var orders=[],boxes=[];'+source});
}
@ -139,7 +175,7 @@ test('cloud reads and password login recover from empty proxy responses without
const result=await page.evaluate(async()=>{
const calls=[],upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid';let scenario='read';
window.fetch=async request=>{calls.push({url:request.url,body:await request.text(),auth:request.headers.get('authorization')});if(scenario==='denied')return new Response('{"error":"denied"}',{status:401});if(scenario==='write')return new Response('',{status:503});return request.url.startsWith(proxy)?new Response('',{headers:{'content-type':'text/html'}}):new Response('[{"id":"company"}]',{headers:{'content-type':'application/json'}})};
const send=window.CateriumCloudTransport.create({upstream,proxy,cooldown:0});
const send=window.CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',cooldown:0});
const read=await (await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',headers:{Authorization:'Bearer test-token'},body:'{}'})).json();
const readCalls=calls.splice(0);
await send(upstream+'/auth/v1/token?grant_type=password',{method:'POST',body:'{"email":"test@example.invalid","password":"test"}'});const authCalls=calls.splice(0);
@ -148,7 +184,7 @@ test('cloud reads and password login recover from empty proxy responses without
scenario='denied';const denied=await send(upstream+'/auth/v1/token?grant_type=password',{method:'POST',body:'{}'});const deniedCalls=calls.splice(0);
return {read,readCalls,authCalls,write:write.status,writeCalls,writeError,emptyWriteCalls,denied:denied.status,deniedCalls};
});
expect(result.read).toEqual([{id:'company'}]);expect(result.readCalls.map(c=>c.url)).toEqual(['https://proxy.example.invalid/rest/v1/rpc/sun_my_workspaces','https://backend.example.invalid/rest/v1/rpc/sun_my_workspaces']);
expect(result.read).toEqual([{id:'company'}]);expect(result.readCalls.map(c=>c.url)).toEqual(['https://proxy.example.invalid/rest/v1/rpc/sun_my_workspaces','https://secondary.example.invalid/rest/v1/rpc/sun_my_workspaces']);
expect(result.readCalls.every(c=>c.auth==='Bearer test-token'&&c.body==='{}')).toBe(true);
expect(result.authCalls).toHaveLength(2);expect(result.authCalls[0].body).toBe(result.authCalls[1].body);
expect(result.write).toBe(503);expect(result.writeCalls).toHaveLength(1);expect(result.writeError).toContain('пустой');expect(result.emptyWriteCalls).toHaveLength(1);
@ -186,29 +222,35 @@ test('a failed company load replaces the stale login form with an actionable ret
await expect(page.getByRole('button',{name:'Повторить загрузку',exact:true})).toBeEnabled();await expect(page.locator('body > header')).toBeHidden();
});
test('real SDK login opens the ordinary app when the proxy returns empty successful responses',async({page})=>{
for(const primaryFails of [false,true])test('real SDK loads orders with foreign services blocked, primary failure='+primaryFails,async({page})=>{
const userId='11111111-1111-4111-8111-111111111111',workspaceId='22222222-2222-4222-8222-222222222222',expires=Math.floor(Date.now()/1000)+3600;
const user={id:userId,aud:'authenticated',role:'authenticated',email:'test@example.invalid',email_confirmed_at:new Date().toISOString(),app_metadata:{provider:'email'},user_metadata:{}};
const token=[{alg:'HS256',typ:'JWT'},{sub:userId,role:'authenticated',aud:'authenticated',exp:expires,iat:expires-3600,aal:'aal1'},'test'].map(x=>typeof x==='string'?x:Buffer.from(JSON.stringify(x)).toString('base64url')).join('.');
const seen=[],warnings=[];page.on('console',m=>{if(m.type()==='warning')warnings.push(m.text())});
const seen=[],foreign=[],sockets=[],warnings=[];
page.on('console',m=>{if(m.type()==='warning')warnings.push(m.text())});page.on('websocket',ws=>sockets.push(ws.url()));
await page.route('https://**',r=>{foreign.push(r.request().url());return r.abort()});
const handle=async route=>{
const request=route.request(),url=new URL(request.url());seen.push(url.host+url.pathname);
const request=route.request(),url=new URL(request.url()),path=url.searchParams.get('__caterium_path')||url.pathname;seen.push({host:url.host,path,method:request.method()});
const headers={'access-control-allow-origin':'*'};
if(request.method()==='OPTIONS')return route.fulfill({status:204,headers});
if(url.host==='api.caterium.ru')return route.fulfill({status:200,contentType:'text/html',body:'',headers});
if(primaryFails&&url.pathname==='/api/index.php')return route.fulfill({status:200,contentType:'text/html',body:'',headers});
let body=null;
if(url.pathname==='/auth/v1/token')body={access_token:token,refresh_token:'test-refresh',token_type:'bearer',expires_in:3600,expires_at:expires,user};
else if(url.pathname==='/auth/v1/user')body=user;
else if(url.pathname.endsWith('/sun_my_workspaces'))body=[{id:workspaceId,name:'Test workspace',role:'admin',is_active:true,permissions:{}}];
else if(url.pathname.endsWith('/sun_is_platform_admin'))body=true;
if(path==='/auth/v1/token')body={access_token:token,refresh_token:'test-refresh',token_type:'bearer',expires_in:3600,expires_at:expires,user};
else if(path==='/auth/v1/user')body=user;
else if(path.endsWith('/sun_my_workspaces'))body=[{id:workspaceId,name:'Test workspace',role:'admin',is_active:true,permissions:{}}];
else if(path.endsWith('/sun_is_platform_admin'))body=false;
else if(path.endsWith('/sun_fetch_app_state'))body=[{revision:1,payload:{format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:[{id:'proxy-order',event:'Заказ без VPN',contact:'Тестовый клиент',phone:'79990000000',address:'Тестовый адрес',total:2400,lines:[]}]},sunBoxes:{t:'j',v:[]}}}}];
else if(path.endsWith('/sun_app_state'))body={revision:1};
return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body),headers});
};
await page.route('**://api.caterium.ru/**',handle);await page.route('**://*.supabase.co/**',handle);
await page.route('**://api.caterium.ru/**',handle);await page.route('**/api/index.php?**',handle);
await page.goto('/index.html',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>window.CateriumAuthSecurityV1774&&window.SunCloudV2?.getClient());
await page.locator('#sunGateEmailV3').fill(user.email);await page.locator('#sunGatePasswordV3').fill('test-password');await page.locator('#sunGateSubmitV3').click();
await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0,{timeout:20000});await expect(page.locator('body > header')).toBeVisible();
expect(await page.evaluate(()=>window.SunCloudV2.getWorkspace()?.id)).toBe(workspaceId);
expect(seen.some(s=>s==='api.caterium.ru/rest/v1/rpc/sun_my_workspaces')).toBe(true);
expect(seen.some(s=>s.endsWith('.supabase.co/rest/v1/rpc/sun_my_workspaces'))).toBe(true);
await expect.poll(()=>page.evaluate(()=>JSON.parse(localStorage.getItem('sunOrders')||'[]').map(o=>o.id))).toContain('proxy-order');
expect(seen.some(s=>s.path==='/auth/v1/token'&&s.host!=='api.caterium.ru')).toBe(true);
expect(seen.some(s=>s.host==='api.caterium.ru')).toBe(primaryFails);
expect(foreign).toEqual([]);expect(sockets).toEqual([]);
expect(warnings.some(s=>s.includes('Multiple GoTrueClient'))).toBe(false);
});

View File

@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent')
check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer');
check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox');
check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4');
check(sw.includes('v103-20260918-help-center')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
check(index.includes('20260918-help-center')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
check(sw.includes('v104-20260918-russia-proxy')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
check(index.includes('20260918-russia-proxy')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned');
check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned');
check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded');
@ -39,7 +39,7 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30
check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match');
check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json');
check(releaseManifest.channel==='production','release manifest channel is production');
check(String(releaseManifest.pwaCache||'').includes('v103-20260918-help-center'),'release manifest points to current PWA cache');
check(String(releaseManifest.pwaCache||'').includes('v104-20260918-russia-proxy'),'release manifest points to current PWA cache');
check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3');
check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=45000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries');
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards');
@ -65,8 +65,8 @@ check(offerWorkspace.includes('PDF и предпросмотр')&&offerWorkspace
check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer');
check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes');
check(pkg.version==='17.7.3','package version is v17.7.3');
check(index.includes('20260918-help-center'),'index cache bust is v17.7.3');
check(sw.includes('v103-20260918-help-center')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
check(index.includes('20260918-russia-proxy'),'index cache bust is v17.7.3');
check(sw.includes('v104-20260918-russia-proxy')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist');
check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active');
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3');

View File

@ -28,8 +28,8 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el
if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos');
const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x));
if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed');
if(!sw.includes('20260918-help-center')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh');
if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-help-center')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current');
if(!sw.includes('20260918-russia-proxy')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh');
if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-russia-proxy')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current');
if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present');
if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present');
if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed');