name: documentos-vps-debug
description: Debug and recover DocCorretor (documentos.rochasalesseguros.com.br) when Docker container is corrupted or failing on VPS.
tags:
- vps
- docker
- tanstack-start
- documentacao
DocCorretor VPS Debug & Recovery
Context
DocCorretor (documentos.rochasalesseguros.com.br) deployed on VPS at 31.97.243.106.
- -
/var/www/documentos2/app/— current production (TanStack Start, Node 20, port 3010, NO Docker) - -
/var/www/documentos/deploy-vps-nodejs/app/— old build context (deprecated) - - Supabase project
dauftiqcvgaydddoxqhhis SHARED between DocCorretor AND ComercialRS — changes affect both - - Upload returns HTTP 500 with no error in Node logs
- -
documents insert failed: 401 { message: 'JWT could not be decoded' }in/var/log/documentos.err.log - - Supabase Management API calls fail with 401 even though the PAT is correct in
.env - -
dist/server/assets/— contains the real client JS/CSS bundles (hash-named likeindex-CcUetFZ-.js,styles-496eUP48.css) - -
dist/client/assets/— empty after rebuild (only contains the SSR-rendered HTML, no JS/CSS) - -
/var/www/documentos2/app/— main project (TanStack Start, NO Docker) - -
/var/www/documentos/deploy-vps-nodejs/app/— original version source (visual reference) - - systemd service:
/etc/systemd/system/documentos.service - - Project
dauftiqcvgaydddoxqhhis shared between DocCorretor AND ComercialRS - - Tables:
clients,documents,allowed_emails,gmail_connections,client_folders(DocCorretor);sales,tasks,profiles,team_members(ComercialRS) - - Schema conflicts possible:
profilesanduser_roleshave different schemas in each app - - Management API PAT:
sbp_REDACTED - - Supabase Dashboard: sales.planoss@gmail.com / tSm?p8n57f+TX5n
- - documentos2 app: outputs to
dist/server/server.jsanddist/client/ - - Dockerfile expects:
.output/server/index.mjs— MISMATCH - - When building, check
vite.config.tsoutput config, not assumptions - - UUID regex validation for
clientId - - Safe filename sanitization (only
a-zA-Z0-9._-, max 200 chars) - - Strict enum validation for
category - - Parameterized queries via Supabase JS client
- - Path must start with
/uploads/ - - No
..sequences allowed - - Path resolves within uploads directory
- - No verbose error output
- -
src/routes/auth.tsx— Removed@lovable/dev/cloud-auth-js, replaced with direct SupabasesignInWithOAuth - -
src/lib/google-token.ts— Removed Lovable, replaced with Supabase Auth OAuth - -
src/routes/auth.callback.tsx— NEW FILE: Supabase OAuth callback handler (created to replace Lovable callback) - - JWT
stateparameter approach for OAuth user identification FAILS - - Any code that tries to
payload.subfrom the access_token JWT getsnull - - The entire "pass JWT in state, decode in callback" pattern does not work with Supabase anon tokens
- -
src/routes/api/public/gmail-oauth-callback.ts— kept to avoid 404 if old redirects exist - -
src/server/gmail.functions.ts— unused - -
src/lib/google-token.ts— unused - - GET requests to
/_serverFn/return HTTP 405 "expected POST method" - - Some popup or iframe scenarios may cause GET instead of POST
- - Prefer direct browser redirects for OAuth flows (no
useServerFnfor auth URLs) - - Key:
gmail_app_password_tecrochasales@gmail.comorgmail.app_password - - Value:
dkjp zbis xubt cjpt - - Email sender:
tecrochasales@gmail.com - - GET requests to
/_serverFn/return HTTP 405 "expected POST method" - - Some popup or iframe scenarios may cause GET instead of POST
- - Prefer direct browser redirects for OAuth flows (no
useServerFnfor auth URLs)
Common Failure: Docker Image Corrupted
Symptom
Container documentos-app is Created (not running) or Restarting, logs show:
`
Error: Cannot find module '/app/.output/server/index.mjs'
`
Root Cause
The Docker image was built with .output/server/index.mjs as entrypoint, but the image itself is missing this file. The image is broken/incomplete.
Debug Steps
`bash
docker ps -a | grep documentos # Check container status
docker logs documentos-app 2>&1 | tail -20 # Get exact error
docker run --rm documentos-app ls /app/.output/server/ # Check what exists in image
`
Recovery Options
Option A — Run via node directly (fastest):
`bash
cd /var/www/documentos2/app
source ~/.nvm/nvm.sh && nvm use 20
PORT=3010 HOST=127.0.0.1 node dist/server/server.js &
Test: curl http://127.0.0.1:3010/
`
Option B — Rebuild Docker image (cleaner):
1. Build in /var/www/documentos2/app with Node 20:
`bash
cd /var/www/documentos2/app
source ~/.nvm/nvm.sh && nvm use 20
npm install && npm run build
`
2. Note: output goes to dist/ not .output/ — update Dockerfile CMD or run directly
3. Build and start container:
`bash
docker build -t documentos-app:latest .
docker stop documentos-app; docker rm documentos-app
docker run -d --name documentos-app -p 127.0.0.1:3010:3000 --restart unless-stopped documentos-app:latest
`
Node Version Issue on VPS
Symptom
Build fails with:
`
You are using Node.js 18.20.8. Vite requires Node.js version 20.19+
ERR_REQUIRE_ESM: require() of ES Module .../lovable-tagger not supported
`
Fix
Use Node 20 instead of Node 18:
`bash
source ~/.nvm/nvm.sh && nvm use 20
or use absolute path:
/root/.nvm/versions/node/v20.20.2/bin/node
`
Critical: systemd Does NOT Load .env Automatically
TanStack Start apps running under systemd (via start-server.mjs) do NOT automatically read .env files. Environment variables must be explicitly declared in the systemd unit's Environment= directives.
Symptoms
Root Cause
The Node.js process receives process.env from systemd's environment, NOT from the .env file in the app directory. Variables like SUPABASE_PAT, SUPABASE_REF, SUPABASE_SERVICE_ROLE_KEY, JWT_SECRET are undefined at runtime.
Fix: Add All Env Vars to systemd Unit
`bash
sudo tee /etc/systemd/system/documentos.service > /dev/null << 'EOF'
[Unit]
Description=DocCorretor App
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/var/www/documentos2/app
ExecStart=/root/.nvm/versions/node/v20.20.2/bin/node start-server.mjs
Restart=always
RestartSec=5
StandardOutput=append:/var/log/documentos.log
StandardError=append:/var/log/documentos.err.log
Environment=SUPABASE_URL=https://dauftiqcvgaydddoxqhh.supabase.co
Environment=SUPABASE_REF=dauftiqcvgaydddoxqhh
Environment="SUPABASE_PAT=sbp_REDACTED"
Environment="SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
Environment="JWT_SECRET=EtKGvIz6g8D1kA=="
Environment=NODE_ENV=production
Environment=HOST=0.0.0.0
Environment=PORT=3010
Environment="VITE_SUPABASE_URL=https://dauftiqcvgaydddoxqhh.supabase.co"
Environment="VITE_SUPABASE_PUBLISHABLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
Environment="VITE_GOOGLE_OAUTH_CLIENT_ID=GOOGLE_CLIENT_ID_REDACTED"
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl restart documentos
`
Verification
`bash
Check error log for Management API calls
sudo tail -20 /var/log/documentos.err.log | grep "JWT could not be decoded"
Should show nothing after fix
`
nginx — Duplicate alias with Regex Location Breaks .mjs Files
Symptom
pdf.worker-B1D2UnXD.mjs returns HTTP 404 in browser console. Other .js files work fine.
Root Cause
This nginx config is WRONG:
`nginx
location /assets/ {
alias /var/www/documentos2/app/dist/client/assets/;
...
}
location ~ ^/assets/.*\.mjs$ {
alias /var/www/documentos2/app/dist/client/assets/; # DUPLICATE alias!
add_header Content-Type "text/javascript";
...
}
`
The regex location's alias conflicts with the prefix location. nginx serves 404 even though the file exists. The .mjs extension is already in mime.types (application/javascript js mjs;).
Fix
Remove the regex location entirely — the prefix location handles .mjs correctly via mime.types:
`nginx
Keep ONLY this:
location /assets/ {
alias /var/www/documentos2/app/dist/client/assets/;
expires -1s;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
try_files $uri =404;
}
Delete the regex location — it BREAKS .mjs files
`
Verification
`bash
curl -s -o /dev/null -w "%{http_code}" 'https://documentos.rochasalesseguros.com.br/assets/pdf.worker-B1D2UnXD.mjs'
Should return 200 (not 404)
sudo nginx -s reload # Apply if needed
`
Supabase Storage — documents Bucket Does Not Exist
Symptom
All supabase.storage.from("documents").upload(), .download(), .remove() calls fail with HTTP 400.
Root Cause
GET /storage/v1/bucket returns [] — the documents bucket was never created in Supabase Storage.
Verification
`bash
curl -s -H "Authorization: Bearer sbp_1d0b..." \
'https://dauftiqcvgaydddoxqhh.supabase.co/storage/v1/bucket' | python3 -m json.tool
`
File Download — All Use nginx Public URL (Not Supabase Storage API)
Pattern Used
All download/preview functions now use fetch() against the public nginx URL:
`ts
const PUBLIC_URL = "https://documentos.rochasalesseguros.com.br";
const storageUrl = (path: string) => ${PUBLIC_URL}${path};
// openPreview, downloadOne, downloadZip — all use:
const res = await fetch(storageUrl(doc.storage_path));
`
Why
Files live on VPS at /var/www/documentos/uploads/ served by nginx. Supabase Storage is not used.
Limitation
Files are publicly accessible to anyone with the URL. No authentication required at file download level.
Upload Flow — Use VPS Endpoint, Not Supabase Storage
Wrong (fails because bucket doesn't exist)
`ts
await supabase.storage.from("documents").upload(path, file, { contentType: file.type });
await supabase.from("documents").insert({ ... }); // RLS may also block
`
Correct (POST to VPS endpoint which saves file + inserts DB record)
`ts
const token = (await supabase.auth.getSession()).data.session?.access_token;
const formData = new FormData();
formData.append("file", f);
formData.append("clientId", id);
formData.append("originalName", f.name);
formData.append("category", initialCat);
const res = await fetch("/api/public/upload-document", {
method: "POST",
headers: { Authorization: Bearer ${token} },
body: formData,
});
const data = await res.json();
`
Delete Flow — Use VPS Endpoint for File + DB Record
Wrong (only deletes file, leaves orphan DB record)
`ts
await supabase.storage.from("documents").remove([doc.storage_path]);
await supabase.from("documents").delete().eq("id", doc.id);
`
Correct (single endpoint handles both)
`ts
const token = (await supabase.auth.getSession()).data.session?.access_token;
await fetch("/api/public/delete-document", {
method: "POST",
headers: { Authorization: Bearer ${token}, "Content-Type": "application/json" },
body: JSON.stringify({ storagePath: doc.storage_path, documentId: doc.id }),
});
`
delete-document.ts — Now Deletes DB Record Too
Updated to accept documentId and delete from public.documents via Management API:
`ts
const delRes = await fetch(
https://api.supabase.com/v1/projects/${SB_REF}/database/query,
{
method: "POST",
headers: { "Content-Type": "application/json", Authorization: Bearer ${PAT} },
body: JSON.stringify({ query: DELETE FROM public.documents WHERE id = '${documentId}' }),
}
);
`
Upload Storage Path on VPS
Files are stored at /var/www/documentos/uploads/{userId}/{clientId}/{storedName}.
nginx serves at https://documentos.rochasalesseguros.com.br/uploads/{userId}/{clientId}/{storedName}.
Symptom
systemctl start documentos fails, journalctl -u documentos shows Error: listen EADDRINUSE: address already in use 0.0.0.0:3010. Node processes pile up (restart counter keeps growing).
Fix
`bash
Find the stuck process
lsof -i :3010
Kill it
kill -9
Then restart
systemctl daemon-reload && systemctl start documentos
`
Common Failure 3: Static Assets 404 (Blank Page)
Symptom
Common Failure 3: Static Assets 404 (Blank Page)
Symptom
Page loads but shows spinner forever, console shows Failed to load resource: 404 for all JS/CSS assets. curl -sI https://documentos....com.br/assets/*.js returns HTTP 200 on the HTML SSR response (Node routing) instead of the actual JS file content.
Root Cause
nginx config proxies /assets/ to Node.js (tanstack-start target: "node-server" does NOT serve static files), but Node returns SSR HTML wrapper as a 200 for asset paths. Browser gets HTML instead of JS → JS fails to parse → blank/pink page.
Fix
Change nginx config from proxy to alias — serve assets directly from filesystem:
`nginx
location /assets/ {
alias /var/www/documentos2/app/dist/client/assets/;
expires 1d;
add_header Cache-Control "public, no-transform";
try_files $uri =404;
}
`
Then: nginx -t && systemctl reload nginx
Verification
`bash
Must return HTTP 200 with actual JS content (application/javascript), not HTML
curl -sI https://documentos.rochasalesseguros.com.br/assets/index-nwIlITaw.js | head -5
`
Then: nginx -t && systemctl reload nginx
Two App Versions on VPS — documentos2 (current) vs deploy-vps-nodejs (original)
The VPS at /var/www/documentos2/app/ is the current production version with the "Link para corretor" feature added (May 28).
The older version lives at /var/www/documentos/deploy-vps-nodejs/app/src/ — this has the original visual styling (before the recent changes).
Both share the same git repo at /var/www/documentos2/app/.git but the repo has ZERO commits (git log → "your current branch 'master' does not have any commits yet") — git cannot be used to restore previous versions.
To restore original visual while keeping the button fix:
1. Copy source files from /var/www/documentos/deploy-vps-nodejs/app/src/ → /var/www/documentos2/app/src/
2. Re-apply the insert fix (remove created_by, add expires_at)
3. Full rebuild + restart
Schema Mismatch Debug Pattern — Always Verify Table Columns First
When an insert fails silently (button does nothing, no visible error):
1. Query the table schema via Supabase Management API to get actual column list
2. Compare against the insert object keys in the code
3. Supabase REST API POST /rest/v1/table with Prefer: return=representation shows the exact error
4. Common mistake: code assumes columns that don't exist (created_by, revoked_at, etc.)
Query table schema:
`bash
curl -s "https://api.supabase.com/v1/projects/dauftiqcvgaydddoxqhh/database/query" \
-H "Authorization: Bearer sbp_REDACTED" \
-H "Content-Type: application/json" \
-d '{"query": "SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = '\''client_upload_links'\'' ORDER BY ordinal_position"}'
`
Common Failure 4: Blank Screen After Rebuild (dist/client/assets/ Goes Empty)
Symptom
After rm -rf dist → npm run build (or any full rebuild), the app shows a blank screen. Console shows all JS/CSS assets returning HTTP 200 but as HTML (not JavaScript). The curl https://documentos....com.br/assets/index-*.js returns the SSR HTML page instead of the JS bundle content.
Root Cause — TanStack Start Build Output Structure
TanStack Start builds to TWO separate directories:
The nginx config serves /assets/ from dist/client/assets/ (via alias /var/www/documentos2/app/dist/client/assets/). Since this directory is empty, nginx either:
1. Returns 404 (no such file), OR
2. Falls back to proxying to Node, which returns SSR HTML wrapper for the asset path
Result: browser receives HTML instead of JavaScript → JS fails to parse → blank/pink/white screen.
Fix (One-Liner After Every Rebuild)
`bash
cp /var/www/documentos2/app/dist/server/assets/* /var/www/documentos2/app/dist/client/assets/
systemctl restart documentos
`
Automated Fix — Add to Build Process
In package.json, modify the build script:
`json
"build": "vite build && cp -r dist/server/assets/* dist/client/assets/"
`
Or create a deploy script:
`bash
#!/bin/bash
cd /var/www/documentos2/app
rm -rf dist node_modules/.vite .vite
source ~/.nvm/nvm.sh && nvm use 20
npm run build
cp -r dist/server/assets/* dist/client/assets/
lsof -i :3010 | grep LISTEN | awk '{print $2}' | xargs kill -9
systemctl daemon-reload && systemctl start documentos
`
Verification After Rebuild
`bash
Should list multiple .js and .css files (not be empty)
ls /var/www/documentos2/app/dist/client/assets/
Should return actual JS content, not HTML
curl -s https://documentos.rochasalesseguros.com.br/assets/index-*.js | head -5
(look for: import { createElement) — if you see
Best test: check HTTP content-type header
curl -s -I 'https://documentos.rochasalesseguros.com.br/assets/index-CcUetFZ-.js' | grep content-type
Should be: application/javascript
NOT: text/html
`
systemctl restart Does NOT Kill Node Process
systemctl restart documentos sometimes leaves the old Node process running on port 3010. Always use kill -9 before systemctl start:
`bash
Find the PID on port 3010
lsof -i :3010 | grep LISTEN
Example output: node 3860998 ... 127.0.0.1:3010 (LISTEN)
Kill it
kill -9 3860998
Then start fresh
systemctl start documentos
`
Build Produces Multiple index-*.js Bundles (TanStack Start Manifest Issue)
After npm run build, the dist/client/assets/ directory contains 6+ index-*.js bundles (e.g. index-B4g9_b_K.js, index-BAp-gTZk.js, index-B9BPGdFe.js, etc.) — this is unusual for a single build.
The HTML references ONE of them (e.g. index-B4g9_b_K.js). The cause is likely the TanStack Start Vite plugin generating multiple manifests during SSR+client bundling.
Verification after build:
`bash
ls dist/client/assets/index-*.js | wc -l # Should be 1, not 6+
`
If multiple bundles exist, the service should still work (nginx picks the one referenced in HTML) but it's a sign of potential SSR configuration issues.
Key Files
Critical Security: Shared Supabase Project
Build Output Paths
Critical Security Issues Fixed
upload-document.ts (SQL Injection) — FIXED
Route at /var/www/documentos2/app/src/routes/api/public/upload-document.ts used raw SQL string interpolation. Fixed with:
delete-document.ts (Path Traversal) — FIXED
Route at /var/www/documentos2/app/src/routes/api/public/delete-document.ts used storagePath directly without sanitization. Fixed with:
Lovable Cloud Dependencies — REMOVED
Gmail OAuth — WRONG APPROACH (Do Not Use)
Critical Discovery: Supabase JWT Has No sub
The Supabase session.access_token JWT is an anon key token — it identifies the project, NOT the user. Its payload looks like:
`json
{
"iss": "supabase",
"ref": "dauftiqcvgaydddoxqhh",
"role": "anon",
"iat": 1772451385,
"exp": 188027385
// NO "sub" field!
}
`
This means:
Current Implementation: mailto: Draft (NOT OAuth Send)
The Gmail OAuth flow was completely removed and replaced with mailto: draft approach:
Flow:
1. User selects documents, fills email input, clicks "Enviar por Email"
2. Browser generates ZIP in-memory (JSZip), triggers download
3. Browser opens Gmail via mailto: URL with subject + body pre-filled
4. Corretor manually attaches ZIP and sends
Implementation in admin.tsx:
`tsx
const openGmailDraft = async () => {
const email = emailInput.value.trim();
if (!email || selectedDocs.length === 0) return;
const zip = new JSZip();
for (const doc of selectedDocs) {
const res = await fetch(doc.url);
const blob = await res.blob();
zip.file(doc.originalName, blob);
}
const blob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = ${clientName || "documentos"}.zip;
a.click();
URL.revokeObjectURL(url);
const subject = encodeURIComponent(Documentos — ${clientName});
const body = encodeURIComponent(
selectedDocs.map((d) => - ${d.originalName}).join("\n")
);
window.open(
https://mail.google.com/mail/?view=cm&fs=1&tf=1&to=${encodeURIComponent(email)}&su=${subject}&body=${body},
"_blank"
);
};
`
Advantages: No OAuth needed, no connection needed, works with any email provider, email comes from corretor's real account.
Limitation: ZIP must be manually attached to the draft (mailto: cannot attach files programmatically due to browser security).
Deprecated OAuth Files (Keep for Reference, Not Used)
These files exist but are no longer called by the UI:
If Future OAuth Is Needed
Pass user.id directly in the state parameter (not the JWT access_token):
`ts
// In browser — BEFORE opening OAuth popup
const state = btoa(JSON.stringify({ userId: user.id, timestamp: Date.now() }));
// Send this as state parameter to Google
// In callback — decode the state
const stateData = JSON.parse(atob(state));
const userId = stateData.userId;
`
TanStack Start Server Functions — CSRF Gotcha
createServerFn({ method: "POST" }) endpoints at /_serverFn/ have CSRF protection. useServerFn() in the browser sends POST with proper headers. However:
App Password (Gmail)
Shared App Password stored in app_settings table:
Gmail OAuth — Popup Flow (Important Fix Applied)
Problem
The original connectGmail() called a TanStack Start useServerFn(getGmailAuthUrl) server function. The server function endpoint /_serverFn/ requires POST (CSRF-protected). Some browsers or configurations caused GET requests, resulting in "expected POST method. Got GET" errors.
Solution Applied
Build the Google OAuth URL directly in the browser — no server function needed:
`tsx
// In admin.tsx connectGmail()
const clientId = import.meta.env.VITE_GOOGLE_OAUTH_CLIENT_ID;
const redirectUri = ${window.location.origin}/api/public/gmail-oauth-callback;
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/userinfo.email",
access_type: "offline",
prompt: "consent",
state: session.access_token, // JWT containing user sub
});
const url = https://accounts.google.com/o/oauth2/v2/auth?${params.toString()};
window.open(url, "gmail-oauth", "width=520,height=640");
`
gmail-oauth-callback.ts — JWT State Token Decode
The callback receives ?code=. The JWT state carries the user's sub. Supabase access tokens are base64url-encoded — decode with:
`ts
const parts = state.split(".");
const payloadJson = Buffer.from(
parts[1].replace(/-/g, "+").replace(/_/g, "/"),
"base64"
).toString("utf8");
const payload = JSON.parse(payloadJson);
userId = payload.sub ?? null;
`
Fallback: Find User by Google Email
If the JWT decode fails or returns no sub, fallback to lookup by Google email in profiles table:
`ts
const { data: profile } = await supabaseAdmin
.from("profiles")
.select("user_id")
.eq("email", googleEmail)
.maybeSingle();
userId = profile?.user_id ?? null;
`
Required Env Var for Client-Side OAuth
`env
VITE_GOOGLE_OAUTH_CLIENT_ID=GOOGLE_CLIENT_ID_REDACTED
`
(Already added to /var/www/documentos2/app/.env)
Why No Cookie/Server Session?
The OAuth callback runs as a redirect from Google's servers (another origin). Browsers don't send the Supabase session cookie on cross-origin redirects. Hence: JWT in state parameter + profile email lookup fallback.
TanStack Start Server Functions — CSRF Gotcha
createServerFn({ method: "POST" }) endpoints at /_serverFn/ have CSRF protection. useServerFn() in the browser sends POST with proper headers. However:
Critical Security Issues in API Routes
upload-document.ts (SQL Injection)
The route at /var/www/documentos2/app/src/routes/api/public/upload-document.ts uses raw SQL string interpolation with user-controlled values (clientId, originalName, category). Must use Supabase JS client's .insert() or parameterized queries instead.
delete-document.ts (Path Traversal)
The route at /var/www/documentos2/app/src/routes/api/public/delete-document.ts uses storagePath directly from request body without sanitizing .. path sequences. Attacker with valid JWT can delete arbitrary files on server.
clients/$id.tsx (No Authorization Check)
Route fetches documents by client_id without verifying the client belongs to the authenticated user. Any user can access any client's documents via direct URL manipulation.