chore: import maquina-comercial-pessoal history via subtree

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: maquina-comercial-pessoal
git-subtree-mainline: f5af038e19
git-subtree-split: a9ef4d776d
This commit is contained in:
Gustavo
2026-09-28 15:50:17 -03:00
co-authored by Claude Sonnet 5
150 changed files with 33449 additions and 0 deletions
@@ -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.