From d62e65ce6a97bce856a2a8e0fa462f8a0d931990 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 18 Aug 2026 11:55:11 -0300 Subject: [PATCH] =?UTF-8?q?ops(OPS52-media-guard):=20reconcile=20do=20medi?= =?UTF-8?q?a:recover=20exige=20MEDIA=5FRECOVER=5FCONFIRM=3D1=20(guard=20de?= =?UTF-8?q?=20inten=C3=A7=C3=A3o=20expl=C3=ADcita)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- docs/CHANGELOG-AGENTS.md | 4 +- .../changelog/2026-08-18-ops52-media-guard.md | 1 + docs/plans/ops52-media-guard-impl.md | 142 ++++++++++++++++++ .../ops52-media-recuperar-arquivos-impl.md | 10 +- .../ops60-seed-posts-guard-escrita-s3.md | 59 ++++++++ scripts/lib/cli.mjs | 10 +- scripts/recover-media.mjs | 21 ++- tests/unit/cliEnvFlags.unit.spec.ts | 44 ++++++ 9 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 docs/changelog/2026-08-18-ops52-media-guard.md create mode 100644 docs/plans/ops52-media-guard-impl.md create mode 100644 docs/plans/ops60-seed-posts-guard-escrita-s3.md create mode 100644 tests/unit/cliEnvFlags.unit.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 1c838b48..71941a2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17)**: the `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; the remote script builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout, and recreates the container. Merge em `main` verde == site atualizado. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em `/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg` e `jorgesolla.jpg`) — re-executar via `pnpm media:recover` (restaura objetos sem tocar no DB; runbook em `docs/plans/ops52-media-recuperar-arquivos-impl.md`); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. +- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17)**: the `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; the remote script builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout, and recreates the container. Merge em `main` verde == site atualizado. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint `http://host.docker.internal:3900` — o container alcança o Garage no host via `extra_hosts: host-gateway` + regras ufw `allow from 10.0.11.0/24 to any port 3900` (INPUT e route/FWD); a workstation/tailnet usa `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em `/api/media/file/...`; exceções sem fonte: `fim-escala-6x1.jpg` e `jorgesolla.jpg`) — re-executar via `pnpm media:recover` (restaura objetos sem tocar no DB; runbook em `docs/plans/ops52-media-recuperar-arquivos-impl.md`; desde OPS52-media-guard o reconcile exige `MEDIA_RECOVER_CONFIRM=1` — guard de intenção explícita para escrita no bucket, `--dry-run`/`--verify` seguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 1fd0fe7c..cfd395a5 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -20,10 +20,12 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-18):** **OPS56**: o `forgejo-pr-automerge` nunca mais reporta falso positivo "já mergeado" com PR aberto. Raiz confirmada no source do servidor (Forgejo 9.0.3, tag v9.0.3): o endpoint `POST /pulls/{n}/merge` responde **200 com corpo vazio** no sucesso (falhas são 404/405/409/500), e o `request()` do `forgejo-api.mjs` convertia isso em `null` — o `autoMerge()` devolvia `null` cru e o CLI imprimia "já mergeado" (exit 0) independente do estado real do PR; o incidente (PR #27 da OPS52, aberto das 01:59 às 02:45) deixou o merge falhar silenciosamente com o PR aberto. Correção: o veredito de `autoMerge` agora é **re-read do PR** após a tentativa (`{ attempted, merged, pr }`) — 200-vazio → confirma no `GET /pulls/{n}`; POST falhou e o PR segue aberto → **re-throw** (falha ruidosa, exit 1); raça perdida (outro ator mergeou) → "já mergeado" só com o PR realmente mergeado; PR fechado sem merge → "fechado sem merge — skip" honesto. `waitForChecks` passou a esperar o `mergeable` amadurecer (false → throw imediato de conflito; null → segue no poll), mesmo timeout de 30 min. CLI sem shadowing de `pr` (const redeclarada). Testes: 6 specs novas em `forgejoApi.unit.spec.ts` (veredito por re-read, POST 405 + PR aberto → reject, raça concorrente, no-op de já-mergeado, poll de mergeable null, fail-fast de mergeable false). Gates: 2318 unit + 791 int + tsc/lint/prettier/knip/check:cycles verdes. Débito deferido: alargar o gatilho do `agent-pr-ready-automerge.yml` para branches não-`cursor/*` (decisão de ops separada). -**Recently resolved (2026-08-18):** **OPS58** — o sync de posts de produção ficou pronto para executar com runbook documentado: `scripts/seed-posts.mjs` ganhou o modo **`--dry-run`** (plan-only: fetch ao vivo + echo do alvo + relatório do que SERIA criado/saltado — zero escrita, pre-flight padrão OPS52-media antes de tocar o DB de prod; argumento desconhecido morre) e a **taxonomy `CLASSIFICATION` foi atualizada** com os 4 slugs novos do jorgesolla.com.br (43 posts no WP vs 39 mapeados): os 3 de campanha/pré-campanha (convenção estadual do PT, plenária "PT tem que disputar o programa de governo", caravana da Chapada) entraram com `tags: ['eleitoral']` para ficarem sob o controle de visibilidade eleitoral do repo (`isPostVisible` fail-closed — critério do tag documentado no seed), e o embate com Bruno Reis em `politica`. A execução em prod (pós-merge, homeserver, env do stack, `ALLOW_REMOTE_DB=true`) segue o runbook: dry-run → sync (create-only, idempotente por slug) → bust do cache `posts` via `/api/revalidate` → verify (`media:recover --verify` para capas + curl nas páginas dos novos). Runbook para as próximas sincronizações: `docs/plans/ops58-sincronizar-posts-prod-impl.md`. +**Recently resolved (2026-08-18):** **OPS52-media-guard** — o modo reconcile (escrita) do `pnpm media:recover` ganhou guard de intenção explícita (achado do /simplify da #10): exige `MEDIA_RECOVER_CONFIRM=1` para armar a escrita no bucket — antes, bastava o conjunto completo de `S3_*` (cópia all-or-nothing de credenciais de prod num worktree + rows locais via db:pull → escrevia covers no bucket de prod; o echo do alvo mostrava, não impedia). `--dry-run`/`--verify` seguem sem flag (não escrevem). Sem a flag, reconcile recusa com exit 1 e a mensagem exibe o comando correto. Implementação no dono dos guards: `isTruthyEnv` novo em `scripts/lib/cli.mjs` (semântica exata `true|1` do `ALLOW_REMOTE_DB` — sem trim/case; `isRemoteDbOverrideSet` passou a delegar, sem twin), unit-testado com pin do comportamento preservado (`TRUE`/`yes` continuam recusados). Runbook do homeserver atualizado com a flag; nota no AGENTS.md. **Recently resolved (2026-08-18):** **OPS57** — o safety net do `forgejo-pr-automerge` agora cobre **todas as PRs same-repo para `main`**, não só `cursor/*`. Motivação (débito deferido na OPS56): dos 25 PRs mergeados no repo, **zero** eram `cursor/*` — 9 de `plans/*`, 11 de worktrees humanos, 5 de `refs/pull/N/head` (era da migração) — o filtro antigo nunca cobriu nenhum PR real, e o incidente OPS52 (PR #27, humano, merge falhou silenciosamente) era exatamente a classe excluída. Mudanças: `if:` do job em `agent-pr-ready-automerge.yml` → `state == 'open' && head.repo.full_name == github.repository` (guarda de fork — o repo é público e uma PR de fork mergeada dispararia o deploy de prod; campos já usados no payload, sem dependência de `is_draft` do webhook); política de draft movida para o CLI `forgejo-pr-automerge.mjs` com o head ref do **re-read da API** — draft `cursor/*` → Ready (pool inalterado); draft não-`cursor/*` → skip exit 0 (**draft = veto do ator, PR humana nunca é forçada**); PRs ready não-`cursor/*` (humanas/plans) passam a mergear com CI verde — redundante no caminho feliz (o fluxo humano já arma `--auto --rebase`) e é o que pega a classe OPS52; `ready_for_review` em PR draft re-dispara o job e mergea. Docs sincronizadas (`docs/AGENT-OPS.md` tabela CI por alvo, `.agents/rules/agent-pr-workflow.mdc` seção Safety net). Rejeitado: `!is_draft` no YAML (campo do payload não verificado, falha fail-open) e allowlist de prefixos (códigos humanos sem prefixo comum — OPS/S1/PUB5). A própria PR desta entrega ainda usa o workflow de `main` (antigo); a próxima PR exercita o gatilho alargado. Sem migration/access/Consent/UI. Testes: nenhum novo (lógica de veredito já coberta pelos specs da OPS56; branch de draft é 3 linhas no CLI); gates unit/int verdes. +**Recently resolved (2026-08-18):** **OPS58** — o sync de posts de produção ficou pronto para executar com runbook documentado: `scripts/seed-posts.mjs` ganhou o modo **`--dry-run`** (plan-only: fetch ao vivo + echo do alvo + relatório do que SERIA criado/saltado — zero escrita, pre-flight padrão OPS52-media antes de tocar o DB de prod; argumento desconhecido morre) e a **taxonomy `CLASSIFICATION` foi atualizada** com os 4 slugs novos do jorgesolla.com.br (43 posts no WP vs 39 mapeados): os 3 de campanha/pré-campanha (convenção estadual do PT, plenária "PT tem que disputar o programa de governo", caravana da Chapada) entraram com `tags: ['eleitoral']` para ficarem sob o controle de visibilidade eleitoral do repo (`isPostVisible` fail-closed — critério do tag documentado no seed), e o embate com Bruno Reis em `politica`. A execução em prod (pós-merge, homeserver, env do stack, `ALLOW_REMOTE_DB=true`) segue o runbook: dry-run → sync (create-only, idempotente por slug) → bust do cache `posts` via `/api/revalidate` → verify (`media:recover --verify` para capas + curl nas páginas dos novos). Runbook para as próximas sincronizações: `docs/plans/ops58-sincronizar-posts-prod-impl.md`. + **Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte. **Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- "`); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (`git ls-remote` de `main` == SHA do job, senão skip "stale"), `flock` de serialização, workspace `~/teqo-deploy` (clone do Forgejo local), `docker login localhost:5000` (REGISTRY_USER/PASSWORD de `~/stack/.env`), build dos stages `runner`+`migrator` no homeserver (`--network stack_default` para a geração estática alcançar o `postgres:5432` do compose; secrets de `~/stack/teqo-1313.env` via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup `docker-compose.yml.pre-`), **migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout**, `docker compose up -d`, espera do healthcheck e smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn `login-options` com body JSON, `POST /api/revalidate` com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + `up -d` da imagem anterior) + job vermelho. **Divergência deliberada da intenção (fato novo):** a intenção assumia o runner no homeserver; verificado (`infra-solla/STATE.md` + ao vivo) que o runner é a `workstation-runner` na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job `runs-on: host` na workstation + todo o deploy no homeserver via SSH", o que manteve **zero secrets novos no Forgejo** e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): `docs/ops/teqo-1313-deploy.md` (novo). Guarda estrutural nova: `tests/unit/deployScript.unit.spec.ts` (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla `STATE.md` anotado à parte. diff --git a/docs/changelog/2026-08-18-ops52-media-guard.md b/docs/changelog/2026-08-18-ops52-media-guard.md new file mode 100644 index 00000000..cd5bcc65 --- /dev/null +++ b/docs/changelog/2026-08-18-ops52-media-guard.md @@ -0,0 +1 @@ +**Recently resolved (2026-08-18):** **OPS52-media-guard** — o modo reconcile (escrita) do `pnpm media:recover` ganhou guard de intenção explícita (achado do /simplify da #10): exige `MEDIA_RECOVER_CONFIRM=1` para armar a escrita no bucket — antes, bastava o conjunto completo de `S3_*` (cópia all-or-nothing de credenciais de prod num worktree + rows locais via db:pull → escrevia covers no bucket de prod; o echo do alvo mostrava, não impedia). `--dry-run`/`--verify` seguem sem flag (não escrevem). Sem a flag, reconcile recusa com exit 1 e a mensagem exibe o comando correto. Implementação no dono dos guards: `isTruthyEnv` novo em `scripts/lib/cli.mjs` (semântica exata `true|1` do `ALLOW_REMOTE_DB` — sem trim/case; `isRemoteDbOverrideSet` passou a delegar, sem twin), unit-testado com pin do comportamento preservado (`TRUE`/`yes` continuam recusados). Runbook do homeserver atualizado com a flag; nota no AGENTS.md. diff --git a/docs/plans/ops52-media-guard-impl.md b/docs/plans/ops52-media-guard-impl.md new file mode 100644 index 00000000..1548956f --- /dev/null +++ b/docs/plans/ops52-media-guard-impl.md @@ -0,0 +1,142 @@ +# Impl: OPS52-media-guard — Guard de intenção explícita para escrita em bucket de produção + +Status: aprovado (gate humano 2026-08-18) +Atualizado em: 2026-08-18 +Issue: #37 (OPS52-media-guard) +Intenção: body da Issue #37 (spec — sem plano de intenção separado; achado do /simplify da #10) +Appetite restante: pequeno (chore P3 — guard fail-closed de ~10 linhas + docs) + +## Leitura da intenção + +- **Outcome:** o modo reconcile (padrão) do `pnpm media:recover` só escreve no + bucket com **intenção explícita** — `MEDIA_RECOVER_CONFIRM=1` — e recusa com + mensagem clara sem ela; `--dry-run` e `--verify` seguem desprotegidos (não + escrevem). O risco fechado: um worktree que receba credenciais S3\_\* de prod + (cópia all-or-nothing do main) rodando reconcile contra rows locais + (`db:pull` inclui media) escreveria covers no bucket de prod — hoje só o + echo do alvo torna visível, não impede. +- **O que NÃO negociar:** fail-closed (sem flag → sem escrita, em qualquer + cenário de DB/alvo); dry-run/verify sem fricção nova; o runbook do + homeserver continua sendo o contrato de execução; zero mudança de + comportamento do reconcile além do novo gate. +- **O que reavaliar:** a forma da confirmação — a Issue propõe + "ex.: `MEDIA_RECOVER_CONFIRM=1` **ou** prompt interativo". Prompt interativo + é pior para este repo: o runbook roda via SSH no homeserver (potencialmente + sem TTY), e o padrão de guard do repo é env flag (`ALLOW_REMOTE_DB`, + `isRemoteDbOverrideSet`). Env flag vence (ver Opções). + +## Abordagem recomendada + +```mermaid +flowchart LR + A[pnpm media:recover] -->|parse args| B{mode == reconcile?} + B -->|não dry-run/verify| C[segue desprotegido — zero escrita] + B -->|sim| D{MEDIA_RECOVER_CONFIRM true/1?} + D -->|não| E[die com o comando correto — sem S3 client, sem DB] + D -->|sim| F[guards existentes + echo do alvo + reconcile] +``` + +**Opções consideradas:** + +- **A — Env flag `MEDIA_RECOVER_CONFIRM` (truthy `true|1`) obrigatória no + reconcile**, com helper genérico `isTruthyEnv` em `scripts/lib/cli.mjs` e + `isRemoteDbOverrideSet` delegando para ele (sem twin). **Recomendada.** +- B — Prompt interativo (`readline`) antes do reconcile. +- C — Flag CLI `--confirm` no parsing de args. + +**Recomendação: A** — paridade total com o guard de DB existente +(`ALLOW_REMOTE_DB=true|1`): funcionamento em runbook não-interativo (SSH, +`set -a; source teqo-1313.env`), sem fricção para dry-run/verify, e o parse de +args do script continua restrito aos modos mutuamente exclusivos +(`--dry-run`/`--verify`). + +**Rejeitadas:** B (trava/falha sem TTY — o runbook roda por SSH no homeserver; +mais código; fora do padrão de guards do repo); C (mistura conceito de modo +com flag de confirmação; env var é o padrão e já é usada no runbook — o +comando `set -a; source` injeta as duas flags juntas). + +### Componentes / mudanças + +- **`isTruthyEnv`** (`scripts/lib/cli.mjs`, novo export puro): `value === 'true' || value === '1'` + — **exatamente** a semântica atual de `isRemoteDbOverrideSet` (sem trim, sem + lowercase: `TRUE`/`yes` continuam recusados — não relaxar guard existente na + refatoração). `isRemoteDbOverrideSet` passa a delegar + (`isTruthyEnv(process.env[ALLOW_REMOTE_DB_FLAG])`) — comportamento idêntico. +- **`scripts/recover-media.mjs`**: constante `MEDIA_RECOVER_CONFIRM`; + imediatamente após o parsing de modo (antes de `assertLocalDatabase`, + `resolveS3StorageEnv` e de qualquer rede): + `if (mode === 'reconcile' && !isTruthyEnv(process.env[MEDIA_RECOVER_CONFIRM])) die(...)` + — mensagem pt-BR com o comando correto (`MEDIA_RECOVER_CONFIRM=1 pnpm media:recover`) + e a alternativa `--dry-run`. Bloco de comentário do header (safety model + + modes) atualizado para documentar o guard. O gate vale para reconcile com + QUALQUER alvo de DB (local ou remoto) — o risco é a escrita no bucket, não a + origem dos dados. +- **`tests/unit/cliEnvFlags.unit.spec.ts`** (novo): `isTruthyEnv` + (`true`/`1` passam; `TRUE`, `yes`, `0`, vazio, ausente recusam) e delegação + de `isRemoteDbOverrideSet` preservando o comportamento (mesmo caso + `ALLOW_REMOTE_DB=TRUE` recusado). +- **Docs:** runbook do `docs/plans/ops52-media-recuperar-arquivos-impl.md` + (passo 3 do runbook ganha `MEDIA_RECOVER_CONFIRM=1` + nota do guard no + cabeçalho do passo 2/3); nota curta no AGENTS.md (parágrafo da media — + `reconcile exige MEDIA_RECOVER_CONFIRM=1`); entrada + `docs/changelog/2026-08-18-ops52-media-guard.md` + `pnpm changelog:build`. +- **Migration:** nenhuma. **Access/Consent:** intocados. **UI:** N/A. +- **`package.json`:** sem mudança (a flag é env, não arg do script). + +## Fases verificáveis + +1. **Helper + testes** — `isTruthyEnv` em `cli.mjs` + refactor de + `isRemoteDbOverrideSet` + `tests/unit/cliEnvFlags.unit.spec.ts`; rodar + `pnpm test:unit`. +2. **Guard no script** — `recover-media.mjs` (gate + header doc); conferir + comportamento: `pnpm media:recover` sem flag → exit 1 com a mensagem + (com S3\_\* ausente local o guard do confirm dispara antes do die de S3 — + ordem verificada); `--dry-run`/`--verify` seguem sem exigência. +3. **Docs + changelog** — runbook (passo 3 + nota), AGENTS.md, + `docs/changelog/2026-08-18-ops52-media-guard.md`, `pnpm changelog:build` + + `pnpm changelog:check`. +4. **Gates finais** — `pnpm gate:fast`, `pnpm format:check`, `pnpm exec knip`, + `pnpm check:cycles`, `pnpm test` (unit+int), `pnpm build` local. + +## Rabbit holes / Não escopo (engenharia) + +- **Testar o reconcile ponta a ponta** — impossível por contrato (sem S3\_\* e + sem DB prod nesta máquina); a prova é o runbook do homeserver (padrão da + OPS52 fase 3). O que é testável (semântica do flag, preservação do + `ALLOW_REMOTE_DB`) é unit-testado. +- **Prompt interativo / TTY detection** — rejeitado (Opção B). +- **Guard para `--dry-run`** — não: não escreve; o Issue pede explicitamente + mantê-los desprotegidos. +- **Renomear `ALLOW_REMOTE_DB`** ou mexer em outros scripts — fora de escopo; + o refactor de `isRemoteDbOverrideSet` é só delegação (uma ortografia do + truthy-env, sem mudança de aceite). +- **Adiado com gatilho:** banir re-escritas `=== '1'`/`=== 'true'` de env-read + no `codebaseConventions` — quando um 4º site aparecer ou no próximo refactor + dos 3 existentes (ver `docs/plans/ops60-seed-posts-guard-escrita-s3.md`, + seção Explicitamente fora). +- **Registrado no simplify (OPS60, #49):** `db:seed:posts` escreve no bucket + via adapter S3 sem flag de intenção — mesma classe de risco; Issue nova com + `depends: [37]`. + +## Riscos e mitigação + +- **R1 — Refatorar `isRemoteDbOverrideSet` relaxa o guard de DB.** + Mitigação: `isTruthyEnv` com a semântica EXATA atual (sem trim/lowercase) e + teste de comportamento que pin o caso `TRUE` recusado. +- **R2 — Runbook desatualizado manda rodar reconcile sem a flag e o guard + recusa (fricção na próxima execução de prod).** Mitigação: runbook editado + no mesmo PR; mensagem do die exibe o comando correto. +- **R3 — Automação futura (CI/homeserver) esquece a flag.** Mitigação: + mensagem de erro autoexplicativa + dry-run disponível como pre-flight; + o contrato é do runbook. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: reconcile recusa sem + `MEDIA_RECOVER_CONFIRM=1` (fail-closed); dry-run/verify desprotegidos +- [ ] Invariantes AGENTS/engineering-standards: identificadores em inglês; + guard reusado do dono (`cli.mjs`) sem twin; zero migration/access/Consent +- [ ] Testes de domínio: `isTruthyEnv` + preservação de `isRemoteDbOverrideSet` + (unit); gates da checklist do AGENTS rodados +- [ ] Docs: runbook atualizado (comando real com a flag) + nota AGENTS.md + + changelog diff --git a/docs/plans/ops52-media-recuperar-arquivos-impl.md b/docs/plans/ops52-media-recuperar-arquivos-impl.md index 56f25127..9a6bf7ac 100644 --- a/docs/plans/ops52-media-recuperar-arquivos-impl.md +++ b/docs/plans/ops52-media-recuperar-arquivos-impl.md @@ -95,7 +95,9 @@ contratos de guard diferentes no mesmo script; o seed é local-first por design) `slugFromFilename(filename)` (remove a última extensão) e a resolução `resolveCoverSource(article)` (`article.coverUrl || inlineCoverUrl(...)`). Unit-testados (padrão dos módulos de `scripts/lib`). -- **`scripts/recover-media.mjs`** (novo): fluxo — +- **`scripts/recover-media.mjs`** (novo): fluxo — _(desde OPS52-media-guard, + #37, o reconcile também exige `MEDIA_RECOVER_CONFIRM=1` antes de qualquer + passo abaixo — ver `docs/plans/ops52-media-guard-impl.md`)_ 1. `loadCliEnv` + `assertLocalDatabase('media:recover', hint do runbook)` + `resolveS3StorageEnv(process.env)` (reuso de `src/utilities/mediaStorage.ts`; aborta se `enabled: false` — recuperação é operação de prod, nunca storage @@ -199,9 +201,11 @@ Ordem das próximas execuções (pós-merge, humano): segundos. Se TODAS as rows derem `erro` (timeout/ECONNREFUSED), o container não alcança o Garage — repetir o diagnóstico do estado acima (firewall + extra_hosts + endpoint) antes de recuperar. -3. **Recuperar:** +3. **Recuperar** (o reconcile escreve no bucket — guard de intenção explícita + OPS52-media-guard exige `MEDIA_RECOVER_CONFIRM=1`; sem ela o script recusa + com a mensagem e o comando correto): ```bash - ALLOW_REMOTE_DB=true pnpm media:recover + MEDIA_RECOVER_CONFIRM=1 ALLOW_REMOTE_DB=true pnpm media:recover ``` Esperado: 40 uploads (`PutObject` key = filename; idempotente por overwrite), exceções reais (sem cover / download 404) listadas no relatório — conferir diff --git a/docs/plans/ops60-seed-posts-guard-escrita-s3.md b/docs/plans/ops60-seed-posts-guard-escrita-s3.md new file mode 100644 index 00000000..4d85c9c5 --- /dev/null +++ b/docs/plans/ops60-seed-posts-guard-escrita-s3.md @@ -0,0 +1,59 @@ +# Plano: OPS60 — Guard de intenção explícita para escrita no bucket via `db:seed:posts` + +Status: rascunho +Atualizado em: 2026-08-18 +Issue: OPS60 (a registrar; achado do /simplify da #37) +Depends: [37] (destrava quando o pai flipar done) +Pai do achado: #10 (OPS52-media — quem fiou o S3 no seed) +Priority: P2 +Kind: chore + +## Problema + +Achado do /simplify da OPS52-media-guard (#37, reviewer 1): o `pnpm db:seed:posts` +(`scripts/seed-posts.mjs:347-348`) faz upload das capas através do adapter de +media configurado — **Garage S3 quando as `S3_*` estão setadas** — sem flag de +intenção dedicada. O provisionador de worktree copia `S3_*` all-or-nothing +(`scripts/lib/worktree-env.mjs:58-77`), então um worktree com credenciais de +prod rodando o seed contra um DB local escreve covers no bucket de **prod** — +a mesma classe de risco que a #37 fechou para `media:recover`, ainda aberta no +seed. O seed é idempotente por slug e sobrescreve o key determinístico — a +escrita é silenciosa e "inofensiva" na aparência. + +## Aceite + +- Rodar `db:seed:posts` (ou o upload de media do seed) contra bucket `S3_*` de + prod exige intenção explícita (ex.: `SEED_MEDIA_CONFIRM=1` ou equivalente do + mesmo padrão `isTruthyEnv` da #37), fail-closed — sem a flag, o seed **não** + escreve media no bucket (ou recusa apontar para bucket não-local). +- O comportamento local (sem `S3_*` → disk) e o upload via admin/CI de prod + ficam intactos; dry-run/semântica de guard do seed preservada. +- Guard no dono: `isTruthyEnv` (cli.mjs, já existe da #37) — sem twin. + +## Direção no codebase + +- `scripts/seed-posts.mjs` — ponto de escrita da media; reusar + `resolveS3StorageEnv` + `isTruthyEnv`. +- Alternativas a decidir na execução (gate humano): flag de env exigida para + escrita com S3 ativo (paridade #37) vs exigir bucket local/explicitado; + `--dry-run` do seed já existente como escape de planejamento. +- Runbook/AGENTS.md com o comando real; changelog. + +## Já resolvido no simplify (não reabrir) + +- Nada adicional: o achado desta Issue é o único do lote. + +## Explicitamente fora (descartes + defers com gatilho) + +- **Defer:** banir re-escritas `=== '1'`/`=== 'true'` de env-read no + `codebaseConventions.unit.spec.ts` — gatilho: quando um 4º site aparecer ou + no próximo refactor dos 3 sites existentes (`run-e2e-affected.mjs`, + `worktree.mjs`, `agent-pool-state.mjs` — os dois primeiros são subset + estrito e o terceiro é outro domínio). +- **Descartados:** echo do confirm no target summary; die com + `ALLOW_REMOTE_DB`; rename `isTruthyEnv`; grep-invariante do wiring do guard + (contrato é o runbook; teste de linha de guard é frágil). + +## Appetite + +~0,5 dia eng (um outcome verificável; sem migration/UI). diff --git a/scripts/lib/cli.mjs b/scripts/lib/cli.mjs index 5733573b..62abfa7d 100644 --- a/scripts/lib/cli.mjs +++ b/scripts/lib/cli.mjs @@ -72,8 +72,14 @@ if (gatewayHost) LOCAL_HOSTS.add(gatewayHost) export const ALLOW_REMOTE_DB_FLAG = 'ALLOW_REMOTE_DB' -export const isRemoteDbOverrideSet = () => - process.env[ALLOW_REMOTE_DB_FLAG] === 'true' || process.env[ALLOW_REMOTE_DB_FLAG] === '1' +/** + * Truthy intent-flag check (the one spelling of the `true`/`1` semantics). + * Deliberately exact: no trim, no case-folding — `TRUE` or `yes` stay refused + * (relaxing a guard is a product decision, not a refactor byproduct). + */ +export const isTruthyEnv = (value) => value === 'true' || value === '1' + +export const isRemoteDbOverrideSet = () => isTruthyEnv(process.env[ALLOW_REMOTE_DB_FLAG]) /** * Port for `next dev` (OPS40). Next's CLI resolves its port via commander's diff --git a/scripts/recover-media.mjs b/scripts/recover-media.mjs index 599557d5..a02c6ca8 100644 --- a/scripts/recover-media.mjs +++ b/scripts/recover-media.mjs @@ -17,6 +17,11 @@ * (resolveS3StorageEnv throws on a partial config) — recovery is a * production-bucket operation, never local storage; --verify skips storage * entirely (it only HEADs public URLs). + * - Reconcile writes objects, so it additionally requires the explicit + * intent flag MEDIA_RECOVER_CONFIRM=1 (OPS52-media-guard): the S3_* envs + * alone must never arm a write — a worktree with a prod-credentials copy + * running reconcile against local rows would write the prod bucket. + * --dry-run/--verify stay guard-free: they never write. * - Refuses a non-local DATABASE_URL unless ALLOW_REMOTE_DB=true is set * explicitly, and echoes the target (DB host, endpoint, bucket, verify * origin) before any write. @@ -29,7 +34,9 @@ * Modes: * pnpm media:recover reconcile — plan + upload every * recoverable cover (PutObject overwrites, - * idempotent by filename) + * idempotent by filename); requires + * MEDIA_RECOVER_CONFIRM=1 (explicit + * write-intent guard) * pnpm media:recover --dry-run plan only — resolve covers + headObject * each key (present/missing), zero writes * pnpm media:recover --verify acceptance check — HEAD every filename @@ -44,7 +51,7 @@ import { NodeHttpHandler } from '@smithy/node-http-handler' import { getPayload } from 'payload' import { assertLocalDatabase } from './assert-local-database.mjs' -import { dieWithLabel, loadCliEnv } from './lib/cli.mjs' +import { dieWithLabel, isTruthyEnv, loadCliEnv } from './lib/cli.mjs' import { fetchArticlesFromWordPress, resolveCoverDownloadUrl, @@ -58,6 +65,8 @@ loadCliEnv() const die = dieWithLabel('media:recover') +const RECOVER_CONFIRM_FLAG = 'MEDIA_RECOVER_CONFIRM' + const PROD_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_SITE_URL || 'https://jorgesolla1313.com.br' const args = new Set(process.argv.slice(2)) @@ -67,6 +76,14 @@ if (args.size > 1) die('modos são mutuamente exclusivos: use apenas um de --dry-run / --verify (ou nenhum).') const mode = args.has('--dry-run') ? 'dry-run' : args.has('--verify') ? 'verify' : 'reconcile' +if (mode === 'reconcile' && !isTruthyEnv(process.env[RECOVER_CONFIRM_FLAG])) { + die( + 'modo reconcile ESCREVE objetos no bucket — exige confirmação explícita de intenção.\n' + + ` Re-rodar com: ${RECOVER_CONFIRM_FLAG}=1 pnpm media:recover\n` + + ' (ou use --dry-run para planejar sem escrever; --verify só HEADs URLs públicas).', + ) +} + assertLocalDatabase( 'media:recover', `Este script LÊ o banco de produção (rows de media) e ESCREVE apenas objetos no bucket S3 das envs S3_*.\n` + diff --git a/tests/unit/cliEnvFlags.unit.spec.ts b/tests/unit/cliEnvFlags.unit.spec.ts new file mode 100644 index 00000000..e8109b36 --- /dev/null +++ b/tests/unit/cliEnvFlags.unit.spec.ts @@ -0,0 +1,44 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it } from 'vitest' + +import { ALLOW_REMOTE_DB_FLAG, isRemoteDbOverrideSet, isTruthyEnv } from '../../scripts/lib/cli.mjs' + +describe('isTruthyEnv (OPS52-media-guard — one spelling of the true/1 intent-flag semantics)', () => { + it('accepts exactly true and 1', () => { + expect(isTruthyEnv('true')).toBe(true) + expect(isTruthyEnv('1')).toBe(true) + }) + + it('refuses every other value — including case variants and empty/absent', () => { + for (const value of ['TRUE', 'True', 'yes', 'on', '0', 'false', '', ' ', '2']) { + expect(isTruthyEnv(value), JSON.stringify(value)).toBe(false) + } + expect(isTruthyEnv(undefined)).toBe(false) + }) +}) + +describe('isRemoteDbOverrideSet (behavior preserved through the delegation refactor)', () => { + const original = process.env[ALLOW_REMOTE_DB_FLAG] + + afterEach(() => { + if (original === undefined) delete process.env[ALLOW_REMOTE_DB_FLAG] + else process.env[ALLOW_REMOTE_DB_FLAG] = original + }) + + it('still passes for true/1', () => { + process.env[ALLOW_REMOTE_DB_FLAG] = 'true' + expect(isRemoteDbOverrideSet()).toBe(true) + process.env[ALLOW_REMOTE_DB_FLAG] = '1' + expect(isRemoteDbOverrideSet()).toBe(true) + }) + + it('still refuses TRUE/yes/empty/absent', () => { + for (const value of ['TRUE', 'yes', '', '0', 'false']) { + process.env[ALLOW_REMOTE_DB_FLAG] = value + expect(isRemoteDbOverrideSet(), JSON.stringify(value)).toBe(false) + } + delete process.env[ALLOW_REMOTE_DB_FLAG] + expect(isRemoteDbOverrideSet()).toBe(false) + }) +})