# Anexo 004 — Cloudflare: inserir domínio na conta Ligbox (wizard VM112)

**Parent:** [spec.md](./spec.md) · Spec **004** onboard-funnel-events  
**Criado:** 2026-06-22 · **Roger**  
**VM:** **112** (`10.10.10.112`) · API wizard `:8090`  
**URL pública:** `https://onboard.ligbox.com.br/api/onboarding/`

Este anexo documenta **onde vive o código**, **funções**, **endpoints**, **fluxo UI** e **specs relacionadas** para criar/inserir um domínio na **conta Cloudflare Ligbox/Ibytera** durante o onboarding (opção «Trazer DNS para o portal»).

---

## Resumo operacional

| Acção | Quem | Quando no wizard |
|-------|------|------------------|
| Validar domínio (formato + Carbonio) | `POST /validate-domain` | Passo 0 — antes do CF |
| **Criar zona na conta CF** | `POST /dns/cloudflare/provision-zone` | Passo DNS — «Trazer para o portal» |
| Aplicar MX/SPF/DKIM/… | `POST /dns/cloudflare/apply` | Após NS no registrador (ou auto sandbox) |
| Verificar propagação | `GET /dns/verify/{domain}` | Antes de criar conta |
| Emitir `dns.applied` (funil Desk) | `apply_cloudflare_dns` router | Após apply OK — ver [research.md](./research.md) |

**Nota:** `provision-zone` **cria a zona**; `apply` **escreve registos DNS** dentro da zona. São passos distintos.

**Spec 037 (2026-06-22):** três contas Ligbox (`ligit`, `itecnologys`, `ibytera`). `GET /dns/resolve/{domain}` descobre a conta; domínio fora das três → BYO (`connect-custom`) ou registrador. Ver [037-dns-multi-cloudflare-orchestration](../037-dns-multi-cloudflare-orchestration/spec.md).

---

## Onde vive o código (VM112)

| Caminho | Papel |
|---------|--------|
| `/opt/ligbox-wizard/backend/app/services/cloudflare.py` | Cliente API Cloudflare (`CloudflareDNS`) |
| `/opt/ligbox-wizard/backend/app/routers/onboarding.py` | Rotas HTTP onboarding + hooks webhook funil |
| `/opt/ligbox-wizard/backend/app/services/dns_verify.py` | Verificação MX/A após apply |
| `/opt/ligbox-wizard/backend/app/config.py` | `cloudflare_account_id`, `load_cloudflare_token_from_secrets()` |
| `/opt/ligbox-wizard/secrets/cloudflare.token` | Token API (não commitar) |
| `/opt/ligbox-wizard/secrets/README.txt` | Instruções token |
| Frontend: `frontend/src/App.jsx` (ou deploy equivalente) | `choosePortalDns()`, `applyPortalDnsRecords()` |
| Serviço: `systemctl status ligbox-wizard` | API `:8090` |

**Repo espelho (dev):** `ibytera-mail-portal` / `ligbox-wizard` no monorepo ou LAPTOP — deploy para VM112 via `scp` + restart.

**Desk VM122 (só leitura):** `projects/ops-desk/api/app/cloudflare_dns.py` — `GET /api/v1/dns/cloudflare/records` — **não cria zona**.

---

## Configuração e credenciais

| Variável / ficheiro | Descrição |
|---------------------|-----------|
| `secrets/cloudflare.token` | API Token Cloudflare (conta Ligbox/Ibytera) |
| `settings.cloudflare_account_id` | Account ID onde zonas são criadas (`POST /zones` body `account.id`) |
| `settings.mail_public_ip` | IP para registos A/MX em `mail_dns_records()` |
| `settings.onboard_sandbox` | Se true → modo sandbox (sem CF real) |
| Token prefix `sandbox_` | Também activa `is_cloudflare_sandbox()` |

### Constitution — segurança token

Fonte: [`.specify/memory/constitution.md`](../../.specify/memory/constitution.md)

> **Cloudflare API:** Tokens com scope mínimo (zone DNS, **não** account-wide).

**Scopes mínimos para provision-zone (produção):**

| Scope | Motivo |
|-------|--------|
| `Account - Zone - Edit` | Criar zona na conta |
| `Zone - Create` | `POST /zones` |
| `Zone - DNS - Edit` | `upsert_record` (MX, SPF, TXT, A) |
| `Zone - Read` | `get_zone_by_name`, list records |

Mensagem de erro no código se faltar permissão:

```text
Token sem permissão para criar zonas.
Adicione «Account - Zone - Edit» e «Zone - Create» ao API token Ibytera.
```

---

## Funções que criam/inserem a zona (conta CF)

Ficheiro: `backend/app/services/cloudflare.py`

### Helpers

| Função | Descrição |
|--------|-----------|
| `is_cloudflare_sandbox(token?)` | `True` se `onboard_sandbox` ou token `sandbox_*` |
| `sandbox_zone(domain)` | Zona fictícia para testes (sem API CF) |
| `wizard_nameservers(zone)` | Extrai NS da zona para UI |
| `mail_dns_records(domain, mail_ip, aliases?)` | Lista dicts MX/SPF/DKIM/A para apply |

### Classe `CloudflareDNS`

| Método | API CF | Descrição |
|--------|--------|-----------|
| `verify_token()` | `GET /user/tokens/verify` ou equivalente | Valida token antes de operações |
| `get_zone_by_name(domain)` | `GET /zones?name=` | Procura zona na conta |
| **`create_zone(domain, account_id?)`** | **`POST /zones`** | **Cria zona `type: full` na conta Ibytera**; se já existe (1061/1093) devolve existente |
| **`ensure_zone(domain)`** | composto | **`get_zone_by_name` → se null → `create_zone`** — **função principal de inserção** |
| `list_zone_names()` | `GET /zones` paginado | Lista domínios na conta |
| `list_records(zone_id)` | `GET /zones/{id}/dns_records` | Registos actuais |
| `upsert_record(zone_id, type, name, content, …)` | POST/PUT dns_records | Idempotente (81058 = já existe) |
| `fqdn(name, zone_name)` | — | Normaliza `@` e subdomínios |

### Corpo `create_zone` (produção)

```json
{
  "name": "exemplo.com.br",
  "account": { "id": "<cloudflare_account_id>" },
  "type": "full",
  "jump_start": false
}
```

---

## Endpoints API (wizard — passo DNS onboard)

Base: `/api/onboarding` · Header obrigatório: `X-Onboarding-Session: <session_id>`

| Método | Path | Handler | Função |
|--------|------|---------|--------|
| POST | `/validate-domain` | `validate_domain` | Valida domínio; emite `onboarding.started` + `domain.validated` |
| GET | `/dns/cloudflare/status/{domain}` | `cloudflare_zone_status` | Estado zona (configurado?, NS, pending) |
| **POST** | **`/dns/cloudflare/provision-zone`** | **`provision_cloudflare_zone`** | **`ensure_zone(domain)` — insere na conta CF** |
| **POST** | **`/dns/cloudflare/apply`** | **`apply_cloudflare_dns`** | Aplica registos mail; emite **`dns.applied`** |
| GET | `/dns/verify/{domain}` | `verify_dns` | Verifica propagação DNS pública |
| GET | `/dns/portal-onboarding/{domain}` | `portal_dns_onboarding` | Payload UI passos NS |
| GET | `/dns/instructions/{domain}` | `dns_instructions` | Instruções alternativas DNS |
| POST | `/account/create` | `create_account` | Conta Carbonio + infra (após DNS OK) |

### `provision-zone` — request/response

**Request:**

```json
{ "domain": "exemplo.com.br" }
```

**Response (campos principais):**

```json
{
  "domain": "exemplo.com.br",
  "zone_id": "…",
  "nameservers": ["…ns.cloudflare.com", "…"],
  "message": "Domínio … criado na Cloudflare Ligbox",
  "sandbox": false,
  "status": { "nameservers": ["…"] },
  "steps": [ … ],
  "verification": { … }
}
```

Em **sandbox**, `provision-zone` pode também aplicar registos simulados e preencher `verification`.

### `apply` — request

```json
{
  "domain": "exemplo.com.br",
  "zone_id": "opcional-se-ja-conhecido",
  "mail_aliases": ["suporte", "vendas"]
}
```

Se zona não existir na conta → HTTP **422** `zone_not_in_account` + `use_portal_onboarding: true`.

---

## Fluxo no wizard (UI)

```mermaid
sequenceDiagram
  participant U as Cliente
  participant UI as Wizard App.jsx
  participant API as VM112 :8090
  participant CF as Cloudflare API
  participant OPS as Desk VM122

  U->>UI: Passo 0 — domínio
  UI->>API: POST /validate-domain
  API-->>OPS: webhook domain.validated (+ started 1x)

  U->>UI: Escolhe «DNS no portal Ligbox»
  UI->>API: POST /dns/cloudflare/provision-zone
  API->>CF: ensure_zone (create se necessário)
  CF-->>API: zone_id + nameservers
  API-->>UI: nameservers + passos

  U->>U: Altera NS no registrador
  UI->>API: POST /dns/cloudflare/apply
  API->>CF: upsert_record (MX, SPF, …)
  API-->>OPS: webhook dns.applied
  UI->>API: GET /dns/verify/{domain}

  U->>UI: Passo conta admin
  UI->>API: POST /account/create
```

### Funções frontend (referência)

| Função JS | Endpoint |
|-----------|----------|
| `choosePortalDns()` | `POST /onboarding/dns/cloudflare/provision-zone` |
| `applyPortalDnsRecords()` / `applyPortalDns()` | `POST /onboarding/dns/cloudflare/apply` |
| `refreshCfStatus()` | `GET /onboarding/dns/cloudflare/status/{domain}` |

Ficheiro típico: `frontend/src/App.jsx` (passo DNS do onboard).

---

## Integração funil Desk (Spec 004)

| Marco wizard | Evento webhook | Hook em `onboarding.py` |
|--------------|----------------|------------------------|
| Primeiro validate-domain OK | `onboarding.started` | `validate_domain` |
| validate-domain OK | `domain.validated` | `validate_domain` |
| **apply CF OK** | **`dns.applied`** | **`apply_cloudflare_dns`** (~L592) |
| Infra pós-conta | `infra.synced` | `create_account` |
| Fim OK | `onboarding.completed` | `create_account` |
| Erro crítico | `onboarding.failed` | `create_account` except |

Fase funil: `domain_validated` → **`dns_applied`** → `account_created` → …

Ver [research.md](./research.md) tabela hook points.

**Gap conhecido:** `provision-zone` **não** emite evento próprio — só cria zona. O funil avança para `dns_applied` no **apply**.

---

## Specs e documentos relacionados

| # | Documento | Relação com Cloudflare onboard |
|---|-----------|-------------------------------|
| **004** | [spec.md](./spec.md) | Funil + `dns.applied` |
| **004** | [research.md](./research.md) | Hook `apply_cloudflare_dns` |
| **004** | [contracts/webhook-funnel-events.md](./contracts/webhook-funnel-events.md) | Payload eventos funil |
| **001** | [specs/001-webhook-vm112-integration/spec.md](../001-webhook-vm112-integration/spec.md) | Webhook base + `domain.validated` |
| **012** | [specs/012-abandoned-onboarding-lead/spec.md](../012-abandoned-onboarding-lead/spec.md) | `onboarding.started` em `account/create` (não validate) |
| **016** | [specs/016-onboard-self-service-prefill/spec.md](../016-onboard-self-service-prefill/spec.md) | Pré-preenche domínio passo 0 |
| **025** | [specs/025-wizard-onboarding-continuity/spec.md](../025-wizard-onboarding-continuity/spec.md) | Fluxo domínio/DNS idempotente |
| **017** | [specs/017-vm112-domain-orchestration/spec.md](../017-vm112-domain-orchestration/spec.md) | **Purge** — apaga zona CF (passo 5) |
| **010** | [specs/010-desk-assist-takeover/spec.md](../010-desk-assist-takeover/spec.md) | `dns.revalidate` / assist passo DNS |
| **010** | [specs/010-admin-domain-validation/spec.md](../010-admin-domain-validation/spec.md) | Checks infra (não cria zona) |
| **019** | [specs/019-email-migration-vm122-execution/spec.md](../019-email-migration-vm122-execution/spec.md) | MX CF bloqueado até gate DNS |
| **035** | [specs/035-ligbox-mail-bundles-foss-openpanel/spec.md](../035-ligbox-mail-bundles-foss-openpanel/spec.md) | Wizard: MX, SPF, DMARC records |
| **027** | [specs/027-desk-rbac-function-matrix/spec.md](../027-desk-rbac-function-matrix/spec.md) | RBAC `cloudflare_dns.read` Desk |
| — | [constitution.md](../../.specify/memory/constitution.md) | Token CF scope mínimo |
| — | `chat-bruto/CHAT_BRUTO_WIZARD_VM112_20260619.txt` | E2E `provision-zone`, sandbox, fixes |

---

## Testes rápidos (curl)

```bash
SESSION="test-$(date +%s)"
BASE="https://onboard.ligbox.com.br/api/onboarding"
HDR=(-H "X-Onboarding-Session: $SESSION" -H "Content-Type: application/json")

# 1. Validar domínio
curl -s -X POST "$BASE/validate-domain" "${HDR[@]}" \
  -d '{"domain":"exemplo.com.br"}' | jq .

# 2. Estado CF
curl -s "$BASE/dns/cloudflare/status/exemplo.com.br" "${HDR[@]}" | jq .

# 3. Inserir zona na conta Ligbox
curl -s -X POST "$BASE/dns/cloudflare/provision-zone" "${HDR[@]}" \
  -d '{"domain":"exemplo.com.br"}' | jq .

# 4. Aplicar registos mail (após NS)
curl -s -X POST "$BASE/dns/cloudflare/apply" "${HDR[@]}" \
  -d '{"domain":"exemplo.com.br"}' | jq .

# 5. Verificar DNS
curl -s "$BASE/dns/verify/exemplo.com.br" "${HDR[@]}" | jq .
```

LAN directo: substituir host por `http://10.10.10.112:8090/api/onboarding`.

---

## Erros comuns

| Sintoma | Causa provável | Acção |
|---------|----------------|-------|
| `Token Cloudflare não configurado` | Falta `secrets/cloudflare.token` | Criar token + restart wizard |
| `Token sem permissão para criar zonas` | Scopes insuficientes | Account Zone Edit + Zone Create |
| `zone_not_in_account` no apply | `provision-zone` não foi corrido | Correr provision primeiro |
| Zona noutra conta CF | Domínio já em CF cliente | Opção «manter no provedor» ou transfer |
| Sandbox sem CF real | Token `sandbox_` ou `onboard_sandbox` | Esperado em dev |

---

## Manutenção deste anexo

Actualizar quando:

- Alterar `cloudflare.py` ou rotas em `onboarding.py`
- Novos eventos funil ligados a CF
- Mudança de conta Cloudflare ou scopes token
- Refactor frontend passo DNS

Após editar: `refresh-spec-driver.sh` no CT130 (regra `spec-driver-sync`).
