feat: add production deployment and runtime safeguards

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-09-28 15:48:44 -03:00
co-authored by Claude Sonnet 5
parent 8ce2fcdfea
commit a9ef4d776d
23 changed files with 1696 additions and 8 deletions
+98
View File
@@ -0,0 +1,98 @@
# Oracle Always Free Docker Deployment
## 1. Create VM
- Use Ubuntu Always Free.
- Prefer Ampere A1 if available.
- Keep boot/block volume within Always Free limits.
## 2. Keep the VM private first
- Do not open `80/tcp`, `443/tcp`, or public DNS yet.
- Restrict `22/tcp` to your IP.
- The initial account is `admin` / `admin`; change it before the panel is reachable from the internet.
## 3. Install Docker
```bash
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
```
## 4. Configure app
```bash
cp .env.production.example .env.production
nano .env.production
cp Caddyfile.example Caddyfile
nano Caddyfile
```
For Telegram alerts:
- Create a bot with BotFather and copy the bot token into `TELEGRAM_BOT_TOKEN`.
- Send a message to the bot from the operator account or group.
- Get the destination `TELEGRAM_CHAT_ID` from the Telegram API or your preferred bot admin tool.
- Keep `TELEGRAM_ALERTS_ENABLED=true` only when both values are configured.
- To disable alerts safely, set `TELEGRAM_ALERTS_ENABLED=false` and leave token/chat fields blank.
- Never commit `.env.production` or share bot tokens, chat IDs, databases, logs, or WhatsApp auth data.
## 5. First startup on SSH-only access
- Keep OCI ingress for `80/tcp` and `443/tcp` closed for this first run.
- Do not start Caddy yet. Start only the app with a temporary localhost-only host port:
```bash
cat > docker-compose.local-first.yml <<'YAML'
services:
app:
ports:
- "127.0.0.1:3910:3910"
YAML
docker compose -f docker-compose.yml -f docker-compose.local-first.yml up -d --build app
docker compose ps
docker compose logs -f app
```
From your workstation, create an SSH tunnel and change the default password before exposing the stack:
```bash
ssh -L 3910:127.0.0.1:3910 ubuntu@YOUR_VM_PUBLIC_IP
```
- Open `http://127.0.0.1:3910` through the tunnel.
- Log in with `admin` / `admin`.
- Open **Configuracoes > Alterar minha senha** and set a strong unique password with at least 8 characters.
- Log out and log back in with the new password.
## 6. Expose HTTPS after password change
- Only after the admin password is changed, update `Caddyfile` to the public domain and point public DNS at the VM.
- Remove the temporary local-only override and start the public stack:
```bash
rm docker-compose.local-first.yml
docker compose up -d --build
```
- Only after the public stack is ready, open OCI ingress for `80/tcp` and `443/tcp` from the internet.
## 7. Smoke test safely
- Keep campaigns paused during deployment validation.
- Do not start real campaigns during smoke tests.
- Keep Google Places and Telegram tokens blank or fake unless intentionally testing those external services.
- Do not connect a real WhatsApp account or send real WhatsApp messages during smoke tests.
- Confirm health without sending:
```bash
curl -i http://127.0.0.1/api/health
docker compose restart app
docker compose ps
docker compose logs --tail=100 app
```
Expected: health is 200, the app container is healthy after restart, data remains in `/app/data`, and no outreach sends are created.
## 8. Backup
```bash
docker compose exec app npm run backup:data
```
## 9. QR recovery
When Telegram says QR is required, open the panel and reconnect WhatsApp.
@@ -0,0 +1,782 @@
# Docker Cloud 24/7 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Run Maquina Comercial Pessoal 24/7 on Oracle Cloud Always Free using Docker Compose, persistent data, HTTPS, backups, and Telegram alerts when WhatsApp or campaign safety requires operator action.
**Architecture:** Add Docker-ready network binding and health endpoints to the app, package it in a Node 24 container, and orchestrate it with Caddy via Docker Compose. Persist all runtime state under `/app/data`, keep restart-safe campaign behavior, and add a focused Telegram alert module with deduplication.
**Tech Stack:** Node.js 24 ESM, SQLite, Baileys, Docker, Docker Compose, Caddy, Telegram Bot API over HTTPS, Node test runner.
## Global Constraints
- Do not remove the current safety behavior that pauses campaigns after server restart.
- Do not send real WhatsApp messages during tests.
- Do not commit `.env.production`, Telegram tokens, database files, WhatsApp auth files, or backups.
- Local default host remains `127.0.0.1`; Docker sets `APP_HOST=0.0.0.0`.
- Persist SQLite, WhatsApp auth, exports, and backups under the mounted `data` directory.
- Telegram alerts must be deduplicated with a cooldown per alert key.
- Telegram token must never be printed to logs.
- Docker healthcheck must use an endpoint that does not require login and does not expose secrets.
- Caddy handles public HTTP/HTTPS; the app container must not publish its internal port directly to the public internet.
- Oracle Cloud target is Always Free Ubuntu VM with Docker Compose.
---
## File Structure
- Modify `src/env.mjs`: add `APP_HOST`, Docker-safe runtime path envs, and Telegram env helpers.
- Modify `scripts/servidor.mjs`: bind to `APP_HOST`, initialize alerting, and notify restart/WhatsApp conditions.
- Modify `src/server/index.mjs`: expose a no-auth health endpoint.
- Create `src/alerts/telegram.mjs`: Telegram client, alert dedupe, and safe notification helpers.
- Create `tests/alerts.test.mjs`: unit tests for Telegram alert behavior and dedupe.
- Modify existing server/env tests: cover `APP_HOST` and health route.
- Create `Dockerfile`: production image using Node 24.
- Create `.dockerignore`: exclude data, auth, backups, test artifacts, and secrets.
- Create `docker-compose.yml`: app + Caddy with persistent volumes and healthcheck.
- Create `Caddyfile.example`: reverse proxy template for a real domain.
- Create `.env.production.example`: production environment template without secrets.
- Create `scripts/backup-data.mjs`: zip SQLite + WhatsApp auth backups with retention.
- Create `docs/deployment/oracle-free-docker.md`: operator deployment runbook.
---
### Task 1: Docker-Ready Environment and Health Endpoint
**Files:**
- Modify: `src/env.mjs`
- Modify: `scripts/servidor.mjs`
- Modify: `src/server/index.mjs`
- Modify/Test: `tests/server.test.mjs`
- Modify/Test: `tests/personal-branding.test.mjs`
**Interfaces:**
- Produces: `APP_HOST: string` exported from `src/env.mjs`
- Produces: unauthenticated `GET /api/health`
- Consumes: existing `APP_PORT`, `paths`, `loadDotEnv`, `createAppServer`
- [ ] **Step 1: Write failing env test for host binding**
Add a test that imports `src/env.mjs` with `process.env.APP_HOST='0.0.0.0'` and asserts the exported value is `0.0.0.0`. Clear the env afterward.
```js
test('env exposes APP_HOST with local-safe fallback', async () => {
const previous = process.env.APP_HOST;
process.env.APP_HOST = '0.0.0.0';
const env = await import(`../src/env.mjs?host-test=${Date.now()}`);
assert.equal(env.APP_HOST, '0.0.0.0');
if (previous === undefined) delete process.env.APP_HOST; else process.env.APP_HOST = previous;
});
```
- [ ] **Step 2: Write failing health route test**
In `tests/server.test.mjs`, create an app server with fake dependencies and assert `GET /api/health` returns 200 without authentication.
```js
test('health endpoint is public and does not expose secrets', async () => {
const { server, baseUrl, close } = await startTestServer();
try {
const res = await fetch(`${baseUrl}/api/health`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.ok(body.app);
assert.ok(body.database);
assert.ok(body.whatsapp);
assert.doesNotMatch(JSON.stringify(body), /token|password|secret|cookie/i);
} finally {
await close(server);
}
});
```
- [ ] **Step 3: Run RED tests**
Run: `node --test tests/server.test.mjs tests/personal-branding.test.mjs`
Expected: FAIL because `APP_HOST` and `/api/health` do not exist yet.
- [ ] **Step 4: Implement `APP_HOST`**
In `src/env.mjs`, add:
```js
export let APP_HOST = envString('APP_HOST', '127.0.0.1');
```
Update `refreshEnvDerivedValues()`:
```js
APP_HOST = envString('APP_HOST', '127.0.0.1');
```
- [ ] **Step 5: Bind server to `APP_HOST`**
In `scripts/servidor.mjs`, import `APP_HOST` and replace:
```js
server.listen(port, '127.0.0.1', () => {
```
with:
```js
server.listen(port, APP_HOST, () => {
```
Update logs so local and Docker hosts are explicit:
```js
log(`Maquina Comercial Pessoal rodando em http://${APP_HOST}:${port}`);
if (APP_HOST === '127.0.0.1') log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.');
else log('modo container/rede: proteja o acesso com proxy HTTPS e senha forte.');
```
- [ ] **Step 6: Implement public health endpoint**
In `src/server/index.mjs`, add a route before auth-protected application routes:
```js
router.add('GET', '/api/health', async (req, res, { json }) => {
json(200, {
ok: true,
app: 'maquina-comercial-pessoal',
database: 'open',
whatsapp: whatsapp.getStatus?.().state || 'UNKNOWN'
});
});
```
If the router context does not expose `whatsapp` there, wire it from the existing `createAppServer({ db, jobs, whatsapp, log })` closure.
- [ ] **Step 7: Run GREEN tests**
Run: `node --test tests/server.test.mjs tests/personal-branding.test.mjs`
Expected: PASS.
- [ ] **Step 8: Run focused syntax check**
Run: `node --check src/env.mjs` and `node --check scripts/servidor.mjs`
Expected: PASS.
---
### Task 2: Telegram Alert Module
**Files:**
- Create: `src/alerts/telegram.mjs`
- Create/Test: `tests/alerts.test.mjs`
- Modify: `src/env.mjs`
- Modify: `.env.example`
**Interfaces:**
- Produces: `createTelegramAlerter({ token, chatId, enabled, fetchImpl, now, cooldownMs, log })`
- Produces: `alerter.alert(key, text): Promise<{ sent: boolean, reason?: string }>`
- Consumes: Telegram Bot API `sendMessage`
- [ ] **Step 1: Write failing tests for disabled, missing config, send success, and dedupe**
Create `tests/alerts.test.mjs`:
```js
import test from 'node:test';
import assert from 'node:assert/strict';
import { createTelegramAlerter } from '../src/alerts/telegram.mjs';
test('telegram alert skips when disabled or missing config', async () => {
const calls = [];
const alerter = createTelegramAlerter({ enabled: false, token: '', chatId: '', fetchImpl: async (...args) => calls.push(args) });
assert.deepEqual(await alerter.alert('qr', 'QR necessario'), { sent: false, reason: 'disabled' });
assert.equal(calls.length, 0);
});
test('telegram alert sends message without leaking token to text', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url, options });
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: '123:SECRET', chatId: '456', fetchImpl });
assert.deepEqual(await alerter.alert('qr', 'WhatsApp precisa de QR'), { sent: true });
assert.equal(calls.length, 1);
assert.match(calls[0].url, /sendMessage$/);
const payload = JSON.parse(calls[0].options.body);
assert.equal(payload.chat_id, '456');
assert.equal(payload.text, 'WhatsApp precisa de QR');
assert.doesNotMatch(payload.text, /SECRET/);
});
test('telegram alert deduplicates per key within cooldown', async () => {
let current = 1000;
const calls = [];
const fetchImpl = async (...args) => {
calls.push(args);
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: 't', chatId: 'c', fetchImpl, now: () => current, cooldownMs: 1000 });
assert.equal((await alerter.alert('same', 'A')).sent, true);
assert.deepEqual(await alerter.alert('same', 'A again'), { sent: false, reason: 'cooldown' });
current = 2501;
assert.equal((await alerter.alert('same', 'A later')).sent, true);
assert.equal(calls.length, 2);
});
```
- [ ] **Step 2: Run RED test**
Run: `node --test tests/alerts.test.mjs`
Expected: FAIL because module does not exist.
- [ ] **Step 3: Implement Telegram alerter**
Create `src/alerts/telegram.mjs`:
```js
export function createTelegramAlerter({
token,
chatId,
enabled = false,
fetchImpl = fetch,
now = () => Date.now(),
cooldownMs = 15 * 60 * 1000,
log = () => {}
} = {}) {
const sentAt = new Map();
const configured = Boolean(token && chatId);
async function alert(key, text) {
if (!enabled) return { sent: false, reason: 'disabled' };
if (!configured) return { sent: false, reason: 'not_configured' };
const last = sentAt.get(key) || 0;
if (now() - last < cooldownMs) return { sent: false, reason: 'cooldown' };
const response = await fetchImpl(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text })
});
if (!response.ok) {
log(`telegram: falha ao enviar alerta (${response.status || 'sem status'})`);
return { sent: false, reason: 'request_failed' };
}
sentAt.set(key, now());
return { sent: true };
}
return { alert };
}
```
- [ ] **Step 4: Add env values**
In `src/env.mjs`, export:
```js
export const telegram = {
enabled: process.env.TELEGRAM_ALERTS_ENABLED === 'true',
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
chatId: process.env.TELEGRAM_CHAT_ID || ''
};
```
After `loadDotEnv()` refresh, this object must reflect `.env`; if constants are initialized too early, use a function instead:
```js
export function telegramConfig() {
return {
enabled: process.env.TELEGRAM_ALERTS_ENABLED === 'true',
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
chatId: process.env.TELEGRAM_CHAT_ID || ''
};
}
```
Prefer the function if tests expose stale values.
- [ ] **Step 5: Document env placeholders**
Append to `.env.example`:
```dotenv
TELEGRAM_ALERTS_ENABLED=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
```
- [ ] **Step 6: Run GREEN tests**
Run: `node --test tests/alerts.test.mjs`
Expected: PASS.
---
### Task 3: Wire Operational Alerts Into Server Lifecycle
**Files:**
- Modify: `scripts/servidor.mjs`
- Modify/Test: `tests/server.test.mjs` or create `tests/server-alerts.test.mjs`
**Interfaces:**
- Consumes: `createTelegramAlerter(...)`
- Consumes: `telegramConfig()` from `src/env.mjs`
- Produces: startup alerts for restart pause and WhatsApp reconnect failures
- [ ] **Step 1: Write failing server alert tests with fake alerter**
Create a small exported helper if needed:
```js
export async function notifyStartupState({ alerter, whatsappStatus, campaignsPausedByRestart }) {
if (campaignsPausedByRestart) await alerter.alert('campaigns-paused-restart', 'Campanhas pausadas apos restart. Revise antes de reiniciar.');
if (whatsappStatus === 'QR_REQUIRED') await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel.');
}
```
Test:
```js
test('startup alerts notify restart pause and QR requirement once', async () => {
const alerts = [];
const alerter = { alert: async (key, text) => alerts.push({ key, text }) };
await notifyStartupState({ alerter, whatsappStatus: 'QR_REQUIRED', campaignsPausedByRestart: true });
assert.deepEqual(alerts.map((a) => a.key), ['campaigns-paused-restart', 'whatsapp-qr']);
});
```
- [ ] **Step 2: Run RED test**
Run: `node --test tests/server-alerts.test.mjs`
Expected: FAIL because helper/wiring does not exist.
- [ ] **Step 3: Implement alert helper**
Create `src/alerts/runtime-alerts.mjs` if keeping `scripts/servidor.mjs` slim:
```js
export async function notifyStartupState({ alerter, whatsappStatus, campaignsPausedByRestart }) {
if (campaignsPausedByRestart) {
await alerter.alert('campaigns-paused-restart', 'Campanhas pausadas apos restart. Acesse o painel, revise a fila e reinicie manualmente.');
}
if (whatsappStatus === 'QR_REQUIRED') {
await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel da Maquina Comercial Pessoal.');
}
}
export async function notifyWhatsAppTransition({ alerter, previousState, nextState }) {
if (previousState === nextState) return;
if (previousState === 'READY' && nextState !== 'READY') {
await alerter.alert('whatsapp-left-ready', `WhatsApp saiu de READY e agora esta ${nextState}.`);
}
if (nextState === 'QR_REQUIRED') {
await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel.');
}
}
```
- [ ] **Step 4: Wire startup alert in `scripts/servidor.mjs`**
After `pauseCampaignsAfterRestart(db)`, determine whether any campaign has `pause_reason='server_restart'`:
```js
const campaignsPausedByRestart = Boolean(db.prepare(
"SELECT 1 FROM outreach_campaigns WHERE status = 'paused' AND pause_reason = 'server_restart' LIMIT 1"
).get());
```
Create alerter:
```js
const tg = telegramConfig();
const alerter = createTelegramAlerter({ enabled: tg.enabled, token: tg.botToken, chatId: tg.chatId, log });
```
After WhatsApp manager exists, call:
```js
await notifyStartupState({ alerter, whatsappStatus: whatsapp.getStatus().state, campaignsPausedByRestart });
```
- [ ] **Step 5: Alert on initial reconnect failure**
Change the existing reconnect catch:
```js
whatsapp.connect().catch(async (error) => {
log(`whatsapp: reconexao inicial falhou: ${error.message}`);
await alerter.alert('whatsapp-initial-reconnect-failed', 'WhatsApp nao reconectou no startup. Acesse o painel e verifique o QR.');
});
```
- [ ] **Step 6: Run focused tests**
Run: `node --test tests/alerts.test.mjs tests/server-alerts.test.mjs`
Expected: PASS.
---
### Task 4: Docker and Compose Packaging
**Files:**
- Create: `Dockerfile`
- Create: `.dockerignore`
- Create: `docker-compose.yml`
- Create: `Caddyfile.example`
- Create: `.env.production.example`
- Modify/Test: `tests/personal-branding.test.mjs`
**Interfaces:**
- Consumes: `APP_HOST`, `APP_PORT`, `APP_DB_PATH`, `APP_WHATSAPP_AUTH_DIR`
- Produces: Docker image runnable by `docker compose up -d --build`
- [ ] **Step 1: Write failing file existence/config test**
Add to `tests/personal-branding.test.mjs`:
```js
test('docker deployment files preserve persistent data and do not include secrets', async () => {
const dockerfile = await read('Dockerfile');
const compose = await read('docker-compose.yml');
const envExample = await read('.env.production.example');
const caddy = await read('Caddyfile.example');
assert.match(dockerfile, /FROM node:24/);
assert.match(compose, /restart: unless-stopped/);
assert.match(compose, /APP_HOST=0\.0\.0\.0/);
assert.match(compose, /APP_DB_PATH=\/app\/data\/maquina-pessoal\.db/);
assert.match(compose, /APP_WHATSAPP_AUTH_DIR=\/app\/data\/runtime\/whatsapp-auth/);
assert.match(caddy, /reverse_proxy app:3910/);
assert.doesNotMatch(envExample, /[0-9]{5,}:[A-Za-z0-9_-]{20,}/);
});
```
- [ ] **Step 2: Run RED test**
Run: `node --test tests/personal-branding.test.mjs`
Expected: FAIL because files do not exist.
- [ ] **Step 3: Create Dockerfile**
```dockerfile
FROM node:24-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY scripts ./scripts
COPY public ./public
COPY prospeccao/config.json ./prospeccao/config.json
RUN mkdir -p /app/data/runtime /app/data/exports /app/data/backups
EXPOSE 3910
CMD ["npm", "start"]
```
- [ ] **Step 4: Create `.dockerignore`**
```gitignore
data
node_modules
test-results
playwright-report
.env
.env.production
*.log
*.sqlite
*.db
.git
```
- [ ] **Step 5: Create `docker-compose.yml`**
```yaml
services:
app:
build: .
restart: unless-stopped
env_file:
- .env.production
environment:
APP_HOST: 0.0.0.0
APP_PORT: 3910
APP_DB_PATH: /app/data/maquina-pessoal.db
APP_WHATSAPP_AUTH_DIR: /app/data/runtime/whatsapp-auth
CNPJ_BASE_DIR: /app/prospeccao/cnpj
volumes:
- ./data:/app/data
expose:
- "3910"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3910/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
app:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
```
- [ ] **Step 6: Create `Caddyfile.example`**
```caddyfile
campanhas.seudominio.com {
encode gzip
reverse_proxy app:3910
}
```
- [ ] **Step 7: Create `.env.production.example`**
```dotenv
APP_RESET_PASSWORD=troque-esta-senha
GOOGLE_PLACES_API_KEY=
PERSONAL_COMMERCIAL_WHATSAPP=
TELEGRAM_ALERTS_ENABLED=true
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
```
- [ ] **Step 8: Run GREEN tests**
Run: `node --test tests/personal-branding.test.mjs`
Expected: PASS.
- [ ] **Step 9: Build Docker image**
Run: `docker compose build app`
Expected: image builds successfully.
---
### Task 5: Backup Script With Retention
**Files:**
- Create: `scripts/backup-data.mjs`
- Create/Test: `tests/backup-data.test.mjs`
- Modify: `package.json`
**Interfaces:**
- Produces: CLI `node scripts/backup-data.mjs`
- Produces: npm script `backup:data`
- Consumes: `paths.data`, `paths.db`, `paths.whatsappAuth`
- [ ] **Step 1: Write failing backup tests**
Create test that uses a temp directory, sample DB file, sample auth file, and asserts a backup file is created and old backups are removed.
```js
test('backup creates archive marker and keeps configured retention', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-backup-'));
const dataDir = path.join(tmp, 'data');
await fs.mkdir(path.join(dataDir, 'runtime', 'whatsapp-auth'), { recursive: true });
await fs.writeFile(path.join(dataDir, 'maquina-pessoal.db'), 'db');
await fs.writeFile(path.join(dataDir, 'runtime', 'whatsapp-auth', 'session.json'), 'auth');
const result = await createBackup({ dataDir, now: () => new Date('2026-08-03T10:00:00Z'), retention: 1 });
assert.match(result.file, /maquina-pessoal-2026-08-03/);
await fs.access(result.file);
});
```
- [ ] **Step 2: Run RED test**
Run: `node --test tests/backup-data.test.mjs`
Expected: FAIL because backup module does not exist.
- [ ] **Step 3: Implement backup helper**
Because the project has no zip dependency, implement a safe tar/gzip through Node `child_process` only on Linux containers, or write a plain `.backup` directory copy. Prefer no new production dependency.
Export:
```js
export async function createBackup({ dataDir = paths.data, now = () => new Date(), retention = 14 } = {}) {
const stamp = now().toISOString().slice(0, 10);
const backupsDir = path.join(dataDir, 'backups');
const target = path.join(backupsDir, `maquina-pessoal-${stamp}`);
await fs.mkdir(target, { recursive: true });
await fs.copyFile(path.join(dataDir, 'maquina-pessoal.db'), path.join(target, 'maquina-pessoal.db'));
await cp(path.join(dataDir, 'runtime', 'whatsapp-auth'), path.join(target, 'whatsapp-auth'), { recursive: true });
await pruneBackups(backupsDir, retention);
return { file: target };
}
```
Use directory backups rather than zip if avoiding dependencies. Document this in the runbook.
- [ ] **Step 4: Add CLI guard**
At bottom of `scripts/backup-data.mjs`:
```js
if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) {
createBackup().then((result) => console.log(`backup criado: ${result.file}`)).catch((error) => {
console.error(`backup falhou: ${error.message}`);
process.exit(1);
});
}
```
- [ ] **Step 5: Add npm script**
In `package.json` scripts:
```json
"backup:data": "node scripts/backup-data.mjs"
```
- [ ] **Step 6: Run GREEN tests**
Run: `node --test tests/backup-data.test.mjs`
Expected: PASS.
---
### Task 6: Deployment Runbook and Final Verification
**Files:**
- Create: `docs/deployment/oracle-free-docker.md`
- Modify: `README.md`
**Interfaces:**
- Consumes: Docker/Compose files and Telegram envs from earlier tasks
- Produces: operator runbook for Oracle Always Free deployment
- [ ] **Step 1: Write runbook**
Create `docs/deployment/oracle-free-docker.md` with exact steps:
```markdown
# Oracle Always Free Docker Deployment
## 1. Create VM
- Use Ubuntu Always Free.
- Prefer Ampere A1 if available.
- Keep boot/block volume within Always Free limits.
## 2. Open ports
- OCI ingress: 80/tcp and 443/tcp from internet.
- Restrict 22/tcp to your IP.
## 3. Install Docker
```bash
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
```
## 4. Configure app
```bash
cp .env.production.example .env.production
nano .env.production
cp Caddyfile.example Caddyfile
nano Caddyfile
```
## 5. Start
```bash
docker compose up -d --build
docker compose ps
docker compose logs -f app
```
## 6. Backup
```bash
docker compose exec app npm run backup:data
```
## 7. QR recovery
When Telegram says QR is required, open the panel and reconnect WhatsApp.
```
```
- [ ] **Step 2: Link runbook from README**
Add a short section to `README.md`:
```markdown
## Deploy 24/7 sem custo
Veja `docs/deployment/oracle-free-docker.md` para rodar no Oracle Cloud Always Free com Docker, Caddy e alertas Telegram.
```
- [ ] **Step 3: Run final automated checks**
Run:
```powershell
npm.cmd test
npm.cmd run test:browser
npm.cmd audit --omit=dev
docker compose config
docker compose build app
```
Expected:
- Node tests pass.
- Browser tests pass or any existing unrelated flake is documented.
- Audit reports 0 production vulnerabilities.
- Compose config validates.
- Docker image builds.
- [ ] **Step 4: Manual non-sending smoke test**
Run:
```powershell
docker compose up -d --build
Invoke-WebRequest http://127.0.0.1/api/health -UseBasicParsing
docker compose restart app
docker compose logs --tail=100 app
```
Expected:
- Health returns 200 through Caddy if locally mapped.
- Data directory remains present after restart.
- Campaigns pause after restart.
- Telegram alert is sent for restart pause if enabled.
- No real WhatsApp message is sent.
---
## Self-Review
- Spec coverage: Oracle Free, Docker Compose, Caddy HTTPS, persistent volumes, Telegram alerts, restart safety, backups, and verification are each assigned to tasks.
- Placeholder scan: no TBD/TODO placeholders remain; every task has concrete files, commands, and expected outcomes.
- Interface consistency: `APP_HOST`, `/api/health`, `createTelegramAlerter`, `notifyStartupState`, and `backup:data` are defined before later tasks consume them.
@@ -0,0 +1,169 @@
# Docker Cloud 24/7 Design
## Goal
Run Maquina Comercial Pessoal continuously in a free cloud environment with Docker, persistent data, safe WhatsApp recovery, and Telegram alerts when manual action is required.
## Chosen Approach
Use Oracle Cloud Always Free with an Ubuntu VM, Docker Compose, persistent volume storage, Caddy for HTTPS, and Telegram Bot API alerts.
This is the best free option because Oracle Always Free can provide an always-on VM and persistent block volume. Other free platforms may sleep or restart unpredictably, which is risky for a WhatsApp Web session and scheduled campaign windows.
## Runtime Architecture
- One Ubuntu VM on Oracle Cloud Always Free.
- Docker Engine and Docker Compose installed on the VM.
- One application container running `npm start`.
- One Caddy container acting as reverse proxy and HTTPS terminator.
- Persistent host directory or block volume mounted into the app container.
- Telegram alerts sent directly from the app using Bot API HTTPS requests.
## Required Application Changes
### Network Binding
The server currently listens on `127.0.0.1`. In Docker it must support binding to `0.0.0.0`.
Add `APP_HOST`, defaulting to `127.0.0.1` for local safety. In Docker set `APP_HOST=0.0.0.0`.
### Persistent Paths
The following paths must survive container restarts and image updates:
- SQLite database: `data/maquina-pessoal.db`
- WhatsApp auth/session: `data/runtime/whatsapp-auth`
- Exports: `data/exports`
- Any runtime logs or backups
Use a single mounted volume at `/app/data` and configure:
- `APP_DB_PATH=/app/data/maquina-pessoal.db`
- `APP_WHATSAPP_AUTH_DIR=/app/data/runtime/whatsapp-auth`
### Health Endpoint
Add or reuse a lightweight HTTP health endpoint suitable for Docker healthcheck and uptime checks.
The endpoint must not leak secrets. It can expose:
- app status
- database open/migrated
- WhatsApp state summary
- campaign scheduler state summary
### Telegram Alerts
Add Telegram configuration:
- `TELEGRAM_BOT_TOKEN`
- `TELEGRAM_CHAT_ID`
- `TELEGRAM_ALERTS_ENABLED=true|false`
Use Telegram Bot API `sendMessage` over HTTPS.
Send alerts for:
- WhatsApp leaves `READY` after previously being ready.
- WhatsApp enters `QR_REQUIRED`.
- Initial reconnect fails on startup.
- Campaigns are paused by `server_restart`.
- Worker sees repeated send errors above a safe threshold.
Alerts must be deduplicated to avoid spam. Use a cooldown window per alert key, for example 15 minutes.
### Restart Semantics
Keep the current safe behavior: campaigns pause after server restart. This prevents blind sending after container or VM restarts.
When this happens, Telegram should notify the operator to review and resume manually.
## Docker Files
### Dockerfile
Use Node 24, install production dependencies, copy app files, expose the app port, and run `npm start`.
Do not bake secrets into the image.
### docker-compose.yml
Services:
- `app`
- build from local Dockerfile
- `restart: unless-stopped`
- `env_file: .env.production`
- mount persistent `./data:/app/data`
- expose app port only to the internal Docker network
- healthcheck hitting the app health endpoint
- `caddy`
- official Caddy image
- `restart: unless-stopped`
- ports `80:80` and `443:443`
- Caddyfile mounted read-only
- Caddy data/config volumes persisted
## HTTPS and Access
Preferred: use a domain pointed to the VM public IP and let Caddy issue Let's Encrypt certificates.
If no domain is available, HTTP by IP can work temporarily but is not recommended for a login panel.
The app already has authentication. The production admin password must be changed from the default before exposing the panel.
## Oracle Cloud Setup
- Create Always Free Ubuntu VM.
- Attach or use persistent boot/block volume within Always Free limits.
- Install Docker and Docker Compose plugin.
- Open OCI security list/NSG for ports `80` and `443` only.
- Restrict SSH to the operator IP when possible.
- Clone/copy the project to the VM.
- Create `.env.production` with production values.
- Run `docker compose up -d --build`.
## Backup Plan
Create a daily SQLite backup job on the host or in a sidecar/script.
Backup target:
- `/app/data/backups/maquina-pessoal-YYYY-MM-DD.zip`
Retain the last 7 to 14 backups to control storage usage.
The backup must include at least:
- SQLite database
- WhatsApp auth directory
## Safety Rules
- Do not send messages automatically after an app/container restart.
- Do not expose Telegram token in logs.
- Do not commit `.env.production`.
- Do not use the real WhatsApp provider in tests.
- Do not remove existing manual approval gates.
- Keep campaign resume a deliberate operator action after Telegram alert.
## Verification
Before production deployment:
- `npm test`
- `npm run test:browser` if browsers are available
- `npm audit --omit=dev`
- Docker build succeeds
- Container healthcheck becomes healthy
- Telegram test alert is received
- App can show QR and reconnect WhatsApp
- Data persists after `docker compose restart`
- Campaign pause after restart triggers Telegram alert
## Open Operational Requirements
- Operator must create a Telegram bot with BotFather.
- Operator must provide `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` locally on the VM.
- Operator should decide whether to use a domain for HTTPS. HTTPS with a domain is recommended before exposing the panel.