diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..c0d9f9a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Preserve the published SDK bytes, including whitespace inside template strings.
+public/vendor/supabase-2.112.4.min.js -text -diff
diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml
index a7260d4..cb93866 100644
--- a/.github/workflows/qa.yml
+++ b/.github/workflows/qa.yml
@@ -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
diff --git a/docs/release-manifest.json b/docs/release-manifest.json
index 2f838b5..78319c6 100644
--- a/docs/release-manifest.json
+++ b/docs/release-manifest.json
@@ -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,
diff --git a/ops/timeweb/README.md b/ops/timeweb/README.md
index dd7dfac..b76b8f7 100644
--- a/ops/timeweb/README.md
+++ b/ops/timeweb/README.md
@@ -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
diff --git a/ops/timeweb/api-proxy.php b/ops/timeweb/api-proxy.php
index 8190d05..11fb0e1 100644
--- a/ops/timeweb/api-proxy.php
+++ b/ops/timeweb/api-proxy.php
@@ -1,23 +1,26 @@
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
, 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;
diff --git a/public/api/index.php b/public/api/index.php
new file mode 100644
index 0000000..11fb0e1
--- /dev/null
+++ b/public/api/index.php
@@ -0,0 +1,185 @@
+ 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
, 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;
diff --git a/public/app-runtime.js b/public/app-runtime.js
index 807a7a3..853be01 100644
--- a/public/app-runtime.js
+++ b/public/app-runtime.js
@@ -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))?'онлайн':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=`
`;}}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=`
`;}}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&¤t())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);
}
diff --git a/public/core/cloud-transport.js b/public/core/cloud-transport.js
index 50c7183..b5c7c19 100644
--- a/public/core/cloud-transport.js
+++ b/public/core/cloud-transport.js
@@ -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(){
'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(){
diff --git a/public/index.html b/public/index.html
index 5852d56..457b0f2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,4 +1,4 @@
-
-
+
diff --git a/public/service-worker.js b/public/service-worker.js
index dbc92e6..9f4f081 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -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}`,
diff --git a/public/vendor/README.md b/public/vendor/README.md
new file mode 100644
index 0000000..6e8d148
--- /dev/null
+++ b/public/vendor/README.md
@@ -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.
diff --git a/public/vendor/supabase-2.112.4.min.js b/public/vendor/supabase-2.112.4.min.js
new file mode 100644
index 0000000..3783bf2
Binary files /dev/null and b/public/vendor/supabase-2.112.4.min.js differ
diff --git a/public/vendor/supabase-LICENSE.txt b/public/vendor/supabase-LICENSE.txt
new file mode 100644
index 0000000..ddeba6a
--- /dev/null
+++ b/public/vendor/supabase-LICENSE.txt
@@ -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.
diff --git a/tests/backend-cutover.mjs b/tests/backend-cutover.mjs
index f374b21..9c76d8a 100644
--- a/tests/backend-cutover.mjs
+++ b/tests/backend-cutover.mjs
@@ -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');
diff --git a/tests/login-recovery.spec.mjs b/tests/login-recovery.spec.mjs
index 4af126b..1e7da9f 100644
--- a/tests/login-recovery.spec.mjs
+++ b/tests/login-recovery.spec.mjs
@@ -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:''}));
+ 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:''}));
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:''}));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);
});
diff --git a/tests/release-check.mjs b/tests/release-check.mjs
index ffa8595..c642811 100644
--- a/tests/release-check.mjs
+++ b/tests/release-check.mjs
@@ -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');
diff --git a/tests/static-security.mjs b/tests/static-security.mjs
index cbb436c..653a48c 100644
--- a/tests/static-security.mjs
+++ b/tests/static-security.mjs
@@ -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');