feat(ops): CLI ops:global para flags de globais públicos (C247)

Grava um único campo do global via Local API e busta a tag no processo Next vivo pelo POST /api/revalidate (allowlist ampliada com REVALIDATE_PHOTO_ALBUM_CACHE_TAG), com guardas de escrita, plan/apply/verify, recibo com rollback e int pin do update parcial.
This commit is contained in:
Francisco Solla 2026-10-02 09:10:19 -03:00
parent d8020607b9
commit 269b3d9e3b
10 changed files with 878 additions and 0 deletions

2
.gitignore vendored
View file

@ -91,6 +91,8 @@ next-env.d.ts
# C232 — photo-archive cataloguing receipts (pnpm archive:catalog)
/data/archive/
/data/face/
# C247 — public-global ops receipts (pnpm ops:global)
/data/ops-global/
# Solla × Ceuci report HTML cache (scripts/build-solla-ceuci-salvador-report.mjs)
/data/solla-ceuci-salvador/

View file

@ -0,0 +1,117 @@
# Impl: C247 — Ligar/desligar globais públicos fora do admin (revalidação + ops)
Status: aprovado (--auto)
Atualizado em: 2026-10-01
Issue: #1419
Intenção: docs/plans/ops-toggle-globais-publicos.md
Appetite restante: herdado
## Leitura da intenção
- **Outcome (não negociar):** a ops liga/desliga uma flag pública operacional por CLI — `pnpm ops:global photoAlbum --selfie-search on` — com guardas, recibo e rollback, e o site reflete em ≤60 s sem admin e sem restart. Impeccable A: sem UI.
- **O que NÃO negociar:** allowlist fechada (nenhuma tag arbitrária); escrita pela Local API (nunca SQL cru); nenhum segundo caminho de permissão; segredo nunca no log, nem em erro; hook fail-closed do `PhotoAlbum` (publicar sem canal de remoção válido continua recusado); Consent/LGPD intocados.
- **O que reavaliar:**
- A recomendação de allowlist da intenção (4 globais operacionais, marcada "assumido — validar") — ver D1.
- "O `revalidateTag` do processo do CLI basta": não basta — o CLI roda com `seed-loader.mjs:20-29` (stuba `next/cache`) e a instância Next é outro processo; o efeito só existe via `POST /api/revalidate` no alvo.
- Update parcial de global: o hook `beforeValidate` global recebe o `data` já mesclado com o documento original no Payload 3.82.0, o que torna o write de um campo único seguro — pinado por int test (ver Componentes).
## Abordagem recomendada
```mermaid
flowchart LR
subgraph OPS["Ops — CLI scripts/ops-global.mjs"]
CMD["pnpm ops:global photoAlbum<br/>--selfie-search on [--apply|--verify]"] --> PLAN["tabela fechada (operação→campo)<br/>plan default"]
PLAN --> GUARD["guardas: OPS_GLOBAL_CONFIRM<br/>TEQO_ENV↔banco · preflight do bust"]
GUARD --> WRITE["payload.updateGlobal<br/>campo único · overrideAccess"]
WRITE --> REC["recibo JSON: previous→new<br/>+ rollback (sem segredo)"]
end
WRITE --> POST["POST /api/revalidate?tag=global_photoAlbum<br/>x-revalidate-secret"]
POST --> EP["route.ts + revalidateRequest.ts<br/>allowlist fechada"]
EP --> BUST["revalidateTag('global_photoAlbum')<br/>no processo Next vivo"]
BUST --> SURF["/fotos · /fotos/encontre · home<br/>≤60 s · sem restart"]
POST -. falha .-> ERR["erro alto + curl de recuperação<br/>(recibo registra revalidation)"]
```
**Opções consideradas (macro):** A) ampliar a allowlist do `POST /api/revalidate` (derivando de `getGlobalCacheTag`) e o CLI faz o bust remoto no alvo; B) rota de ops dedicada com o mesmo segredo; C) `revalidateTag` dentro do processo do CLI.
**Recomendação:** A — o endpoint já é o dono do bust remoto (auth `timingSafeEqual`, tag por query/body, allowlist testada em `tests/unit/revalidateRequest.unit.spec.ts`); o CLI reusa o padrão de `scripts/lib/instagramContentCli.mjs:129-154`.
**Rejeitadas:** B porque cria uma segunda superfície de permissão/rota para o mesmo efeito (anti-goal explícito da intenção: "criar segundo caminho de permissão") e duplica a auth; C porque não existe — `unstable_cache` é por processo, o `seed-loader.mjs:20-29` stuba `next/cache` no CLI (o `revalidateTag` local é no-op) e produção é o container Next vivo.
### Decisões de engenharia (caro de reverter)
**D1 — Escopo da allowlist:** A) só `global_photoAlbum` agora, com gatilho de revisitação; B) os 4 operacionais públicos do plano de intenção (`photoAlbum`, `site-settings`, `home`, `metadata`); C) derivar a allowlist de todos os globais do config.
**Recomendação:** A — a allowlist é superfície de segurança (`src/utilities/revalidateRequest.ts:32-44`) e só cresce junto com um escritor real: hoje o único caminho de escrita por CLI é o `photoAlbum` (a tabela de operações desta entrega não tem operação para os outros três; entradas extras seriam tags mortas). Mantém o pin que rejeita `global_metadata` (`tests/unit/revalidateRequest.unit.spec.ts:49-57`) como guarda viva de "global fora do escopo é recusado".
**Rejeitadas:** B — divergência registrada da recomendação "assumido — validar" da intenção: satisfaz o mesmo aceite com menos superfície e sem tag sem escritor; **gatilho para revisitar** — no mesmo change em que uma operação nova entrar na tabela, a tag do global dela entra na allowlist e no unit. C porque tag arbitrária/derivada do config é exatamente o que o aceite proíbe.
**D2 — Superfície de escrita do CLI:** A) tabela fechada de operações booleanas (`selfie-search → photoAlbum.selfieSearchEnabled`, `published → photoAlbum.published`), valores `on|off` exatos; B) `--set <field>=<value>` genérico; C) comando one-off `ops:selfie-search`; D) UPDATE via SQL.
**Recomendação:** A — o comando não pode setar campo arbitrário; flag nova só entra por edição da tabela (revisável e testável pura em `scripts/lib/`).
**Rejeitadas:** B porque coage tipos, aceita campos não operados e não permite guarda por campo (ex.: canal de remoção para publicar); C porque repete guardas/recibo por flag e não estabelece o caminho de primeira classe que é o item; D porque ignora hooks/validações (anti-goal da intenção).
**D3 — Falha do bust depois da escrita:** A) erro alto (exit 1) + curl de recuperação no stderr e no recibo, com preflight de `NEXT_PUBLIC_SITE_URL`/`REVALIDATE_SECRET` antes de escrever; B) best-effort como o `instagramContentCli` (registra a falha, run segue verde); C) "testar" o endpoint antes da escrita.
**Recomendação:** A — o bust é o ponto do comando: falha silenciosa recria exatamente o atrito do C242. O preflight evita a escrita que já nasce sem contrato; se o POST falhar depois, a escrita existe e o recibo carrega o valor anterior e a receita de recuperação.
**Rejeitadas:** B porque no import do Instagram a revalidação é acessória — aqui ela é o outcome; C porque o endpoint não tem dry-run: um POST de teste já busta, então o preflight só pode validar presença de envs.
**D4 — Granularidade do comando:** A) exatamente uma operação por invocação; B) várias flags no mesmo run.
**Recomendação:** A — rollback = mesmo comando com o valor anterior (requisito do aceite), recibo 1:1 e parser simples.
**Rejeitada:** B por não ter necessidade real hoje e por compor recibo/rollback sem ganho.
### Componentes / mudanças
- **`REVALIDATE_PHOTO_ALBUM_CACHE_TAG`** (`src/utilities/revalidateRequest.ts`): novo `= getGlobalCacheTag('photoAlbum')`, incluído em `ALLOWED_REVALIDATE_TAGS` e exportado para o pin unit. A rota (`src/app/(frontend)/api/revalidate/route.ts`) não muda: o dono da allowlist é este módulo.
- **`OPS_GLOBAL_TARGETS`** (`scripts/lib/opsGlobal.mjs`, novo — puro, sem I/O): tabela fechada `photoAlbum → { 'selfie-search': { field: 'selfieSearchEnabled', label }, published: { field: 'published', label } }`; nada fora dela é escrito.
- **`parseOpsGlobalCliArgs`** (`scripts/lib/opsGlobal.mjs`): positional `photoAlbum`, exatamente 1 operação, valor `on|off` exato, modos `plan` (default) | `--apply` | `--verify` (`--apply` e `--verify` mutuamente exclusivos), `--help`; erro em desconhecido/valor inválido/múltiplas operações.
- **`buildOpsGlobalWrite`** (`scripts/lib/opsGlobal.mjs`): operação+valor+doc atual → `{ data: { [field]: boolean }, previousValue, newValue, changed, rollbackCommand }`; `data` parcial de um único campo.
- **`formatOpsGlobalReport` / `opsGlobalReportStamp` / `formatOpsGlobalRecoveryCommand`** (`scripts/lib/opsGlobal.mjs`): linhas humanas, nome do recibo e receitas de rollback/recuperação sem segredo.
- **`main()`** (`scripts/ops-global.mjs`, novo): `loadCliEnv`; lê o global (`payload.findGlobal`, `overrideAccess: true` com comentário `bypass`); `plan` imprime previous→new e grava recibo; `--apply` roda `assertWriteConfirm({ flag: 'OPS_GLOBAL_CONFIRM' })` + `assertEnvironmentDatabaseTarget()` (`scripts/lib/cli.mjs:191-320`) + preflight (`NEXT_PUBLIC_SITE_URL`/`REVALIDATE_SECRET` presentes; para `published on`, `isArchivePhotoRemovalChannelUrl` do doc atual, espelho de `scripts/publish-archive-photos.mjs:120-127`), escreve com `payload.updateGlobal`, resolve a tag via `getGlobalCacheTag(slug)` importado de `../src/utilities/globals.ts` e faz `POST {NEXT_PUBLIC_SITE_URL}/api/revalidate?tag=<tag>` com `x-revalidate-secret` e timeout 15 s; falha do POST → erro alto (exit 1) com curl de recuperação; `--verify` relê e compara (exit 1 se divergente); recibo em `data/ops-global/reports/ops-global-<stamp>.json` via `writeRepoFile` (`scripts/lib/cli.mjs`).
- **`package.json`**: script `"ops:global"` no padrão dos CLIs (`--import=tsx/esm --import=./scripts/seed-loader.mjs`).
- **`.gitignore`**: `/data/ops-global/`.
- **Garantia do update parcial (confirmada no código):** `node_modules/payload/dist/globals/operations/update.js` roda o `beforeValidate` de campos antes dos hooks globais, e `getFallbackValue.js` copia o valor do `originalDoc` para campos ausentes — logo `validateRemovalChannel` (`src/globals/PhotoAlbum.ts:22-37`) vê `published`/`removalChannelUrl` mesclados. Pin int novo em `tests/int/archivePhotoPublicRead.int.spec.ts`: parcial `{ selfieSearchEnabled: true }` passa sem apagar os irmãos; parcial `{ published: true }` com canal armazenado inválido continua recusando (fail-closed preservado).
- **Migration:** sem migration (nenhum campo/schema novo).
- **Access / Consent:** nenhum caminho novo de access; `overrideAccess: true` no script com comentário "bypass" (convenção P3-E; o pin do `codebaseConventions` só varre `src/`); Consent intocado; o guard do `PhotoAlbum` permanece fail-closed.
- **UI:** não (Impeccable A) — nenhuma rota ou componente muda; o efeito é cache bust + flag já renderizada por `/fotos`, `/fotos/encontre` e home.
- **Runbook** (`docs/ops/teqo-1313-deploy.md`): 2–3 linhas — no passo 5 da Abertura o CLI passa a ser o caminho principal (`--selfie-search on --apply`, admin como alternativa) e a receita de rollback (`--selfie-search off --apply`); nada além.
- **Changelog:** `docs/changelog/2026-10-01-c247.md` é do fechamento, não deste plano.
### Dados → forma
Não aplicável — a intenção declara "não vou apresentar dados"; o único artefato é o recibo JSON operacional (não é superfície de apresentação).
## Fases verificáveis
1. **Tracer / schema+server** (~0,5 dia — todo o appetite): allowlist + tag export (`revalidateRequest.ts`) → módulo puro + `tests/unit/opsGlobal.unit.spec.ts` (defaults, modos, valor `on|off`, rollback, recibo) → CLI + `package.json` + `.gitignore` → pin int do update parcial → `pnpm test:unit` + rodada targeted do int tocado. Sem migration, sem UI.
2. **UI** — n/a (Impeccable A).
3. **Gates** — `pnpm gate:fast` (lint + typecheck + unit) e push via `pnpm push`; o CI `checks` roda int/e2e completos.
4. **Verificação de efeito (aceite operacional)** — smoke no worktree dev (servidor local + `REVALIDATE_SECRET` local) e/ou staging pós-merge, com a mesma receita do runbook: `--selfie-search on --apply` → espera limitada (≤60 s, sem restart) conferindo `/fotos`, `/fotos/encontre` e a seção da home → `--selfie-search off --apply` (rollback) conferindo o fechamento.
## Rabbit holes / Não escopo (engenharia)
- **`revalidateGlobal` → wrapper seguro:** não trocar o owner (`src/utilities/globals.ts:16-17`) nesta entrega; o CLI usa `seed-loader` e o caso genérico de Local API fora do Next já tem `revalidateTagSafely` (`src/utilities/documents.ts:27-34`). **Gatilho:** um consumidor fora do Next que não possa usar o seed-loader.
- **Extrair helper compartilhado do POST remoto** (Instagram + este): não agora — são 2 call sites; a regra do repo é encapsular a partir do 3º (**gatilho:** 3º call site ou o próximo toque no CLI do Instagram).
- Multi-op por invocação, `--set` genérico, campo não booleano, allowlist dinâmica/ampla, rota de ops dedicada, UI/admin, SQL cru.
- E2E: sem benefício — o efeito é cache bust + flag já coberta por `tests/e2e/frontendFotos.e2e.spec.ts` / `frontendFotosSelfie.e2e.spec.ts`; o CLI não roda no `webServer` do Playwright.
- Migrations existentes: nenhuma é tocada (não há schema novo).
## Riscos e mitigação
- **Bust pós-escrita falha (rede/segredo ausente):** preflight de envs antes de escrever + erro alto + curl de recuperação no stderr e no recibo + rollback documentado; nunca silencioso (difere do best-effort do Instagram por desenho).
- **Allowlist cresce por engano:** entrada derivada de `getGlobalCacheTag`; unit aceita `global_photoAlbum` e segue rejeitando `global_metadata`/`global_home`/`global_site-settings`.
- **Update parcial apaga irmãos:** `data` de um único campo + int pin do merge do Payload.
- **`published on` sem canal:** preflight espelha `publish-archive-photos` e o hook segue fail-closed; o CLI falha antes de escrever com mensagem honesta.
- **Drift de tag (typo):** CLI deriva do owner `getGlobalCacheTag`, endpoint usa a constante derivada do mesmo owner, e o unit casa as duas.
- **Default `posts` do endpoint (`resolveRevalidateTag`):** o CLI nunca omite a tag — a operação sempre produz `?tag=`; o parser/plano é unit-testado para não gerar receita sem tag.
- **Alvo errado (staging × produção):** `assertEnvironmentDatabaseTarget` casa `TEQO_ENV` ao banco e o recibo grava o `target`; o runbook mostra o env file do alvo. O segredo nunca entra no recibo nem no log (nem em erro).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto — comando com guardas + bust via allowlist + efeito nas três superfícies + recibo/rollback; D1 comprovado por unit (tag única) e sem UI.
- [ ] Invariantes AGENTS/engineering-standards — Local API (sem SQL), sem novo caminho de access, sem consent novo, `overrideAccess` com comentário "bypass", sem módulo novo top-level em `src/utilities`, sem migration.
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — unit da tabela/modos/recibo, unit da allowlist ampliada, int do update parcial fail-closed; e2e justificado como desnecessário.
## Self-score (decision-quality)
**5/5**
1. Decisões caras com rejeitadas: sim — D1 (allowlist), D2 (superfície de escrita), D3 (falha do bust), D4 (granularidade), cada uma com o porquê e a alternativa recusada.
2. Cabe no appetite: sim — 1 constante + 1 módulo puro + 1 CLI + 1 script `package.json` + 1 linha de `.gitignore` + 3 testes editados/novos + 2–3 linhas de runbook; ~0,5 dia.
3. Rabbit holes nomeados: sim — `revalidateGlobal`, helper DRY <3 call sites, e2e, SQL cru, allowlist dinâmica.
4. Depth check: reusa `scripts/lib/cli.mjs` (guardas/`writeRepoFile`), o padrão de recibo de `archivePublishPlan.mjs`, o endpoint e o owner `getGlobalCacheTag`; não cria arquivo/utility nova em `src/`.
5. Intenção preservada: sim — o outcome e todos os itens do aceite continuam cobertos; a única divergência (D1: só `photoAlbum` agora) foi registrada com justificativa e gatilho, sem reescrever o outcome.

View file

@ -42,6 +42,7 @@
"faces:index": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/index-archive-faces.mjs",
"faces:enroll-figure": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-figure.mjs",
"archive:publish": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/publish-archive-photos.mjs",
"ops:global": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ops-global.mjs",
"seed:face-consents": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-consents.mjs",
"seed:face-figures": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-figures.mjs",
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",

204
scripts/lib/opsGlobal.mjs Normal file
View file

@ -0,0 +1,204 @@
/**
* C247 — pure planning/reporting of `pnpm ops:global`: the closed table of
* operational public-global flags, the argv parser and the receipt helpers.
* No I/O of its own, so the unit spec drives it with fakes.
*
* The table is the write surface AND the HELP source: a flag only becomes
* writable by editing it here (reviewable, unit-pinned). Nothing outside it is
* ever written.
*/
export const OPS_GLOBAL_DEFAULT_OUT_DIR = 'data/ops-global'
export const OPS_GLOBAL_WRITE_CONFIRM_FLAG = 'OPS_GLOBAL_CONFIRM'
const FLAG_VALUE_ON = 'on'
const FLAG_VALUE_OFF = 'off'
const USAGE =
'uso: `pnpm ops:global <global> --<flag> <on|off> [--apply|--verify]` — ex.: `pnpm ops:global photoAlbum --selfie-search on`'
export const OPS_GLOBAL_TARGETS = Object.freeze({
photoAlbum: Object.freeze({
flags: Object.freeze({
'selfie-search': Object.freeze({
field: 'selfieSearchEnabled',
label: 'Busca por selfie (/fotos/encontre)',
defaultValue: false,
}),
published: Object.freeze({
field: 'published',
label: 'Álbum público (/fotos)',
defaultValue: true,
}),
}),
}),
})
/** The HELP operation block, generated from the table — never a second source. */
export const formatOpsGlobalOperations = () =>
Object.entries(OPS_GLOBAL_TARGETS).flatMap(([slug, target]) =>
Object.entries(target.flags).map(
([flag, definition]) => ` ${slug} --${flag} <on|off> ${definition.label}`,
),
)
export const opsGlobalReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-')
/** `on`/`off` → boolean; anything else is refused. */
const parseFlagValue = (raw, operation) => {
if (raw === FLAG_VALUE_ON) return true
if (raw === FLAG_VALUE_OFF) return false
throw new Error(`valor inválido para --${operation}: "${raw}" (use on|off) — ${USAGE}.`)
}
/**
* @param {string[]} [argv]
* @returns {{ slug: string | null, operation: string | null, value: boolean | null, mode: 'plan' | 'apply' | 'verify', help: boolean }}
*/
export const parseOpsGlobalCliArgs = (argv = process.argv.slice(2)) => {
const options = { slug: null, operation: null, value: null, mode: 'plan', help: false }
const positionals = []
let requestedOperation = null
let requestedValue = null
let sawApply = false
let sawVerify = false
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--help' || arg === '-h') {
options.help = true
continue
}
if (arg === '--apply') {
sawApply = true
continue
}
if (arg === '--verify') {
sawVerify = true
continue
}
if (arg.startsWith('--')) {
if (requestedOperation !== null) {
throw new Error(
`mais de uma operação (--${requestedOperation} e --${arg.slice(2)}) — ${USAGE}.`,
)
}
requestedOperation = arg.slice(2)
const next = argv[++index]
if (next === undefined || next.startsWith('--')) {
throw new Error(`faltou valor para --${requestedOperation} (on|off) — ${USAGE}.`)
}
requestedValue = next
continue
}
positionals.push(arg)
}
if (options.help) return options
if (sawApply && sawVerify) {
throw new Error(`--apply e --verify são mutuamente exclusivos — ${USAGE}.`)
}
if (positionals.length !== 1) {
throw new Error(
positionals.length === 0
? `informe o global (ex.: photoAlbum) — ${USAGE}.`
: `argumento posicional desconhecido: ${positionals.slice(1).join(', ')} — ${USAGE}.`,
)
}
options.slug = positionals[0]
if (!Object.hasOwn(OPS_GLOBAL_TARGETS, options.slug)) {
throw new Error(
`global "${options.slug}" fora da tabela de operações (${Object.keys(OPS_GLOBAL_TARGETS).join(', ')}) — ${USAGE}.`,
)
}
if (requestedOperation === null) {
throw new Error(`informe a operação (ex.: --selfie-search on) — ${USAGE}.`)
}
const flag = OPS_GLOBAL_TARGETS[options.slug].flags[requestedOperation]
if (flag === undefined) {
throw new Error(
`operação "--${requestedOperation}" fora da tabela de ${options.slug} (${Object.keys(OPS_GLOBAL_TARGETS[options.slug].flags).join(', ')}) — ${USAGE}.`,
)
}
options.operation = requestedOperation
options.value = parseFlagValue(requestedValue, requestedOperation)
options.mode = sawApply ? 'apply' : sawVerify ? 'verify' : 'plan'
return options
}
/**
* The write plan: a single-field partial update, its honest previous value
* (the table default when the row/slot is absent) and the rollback command.
*
* @param {{ slug: string, operation: string, value: boolean, currentDoc?: Record<string, unknown> | null }} options
*/
export const buildOpsGlobalWrite = ({ slug, operation, value, currentDoc = null }) => {
const flag = OPS_GLOBAL_TARGETS[slug]?.flags?.[operation]
if (flag === undefined) {
throw new Error(`operação "${operation}" fora da tabela de ${slug}.`)
}
const rawPrevious = currentDoc?.[flag.field]
const previousValue = rawPrevious === undefined ? flag.defaultValue : rawPrevious === true
return {
slug,
operation,
field: flag.field,
previousValue,
newValue: value,
changed: previousValue !== value,
data: { [flag.field]: value },
rollbackCommand: formatOpsGlobalCommand(slug, operation, previousValue),
}
}
/** The same command with the previous value — the rollback recipe. */
export const formatOpsGlobalCommand = (slug, operation, value) =>
`pnpm ops:global ${slug} --${operation} ${value ? FLAG_VALUE_ON : FLAG_VALUE_OFF} --apply`
/**
* The manual recovery when the bust fails after the write: no secret value is
* ever printed — the env var is referenced by name.
*/
export const formatOpsGlobalRecoveryCommand = ({ baseUrl, tag }) =>
`curl -X POST ${baseUrl}/api/revalidate?tag=${encodeURIComponent(tag)} -H "x-revalidate-secret: $REVALIDATE_SECRET"`
export const formatOpsGlobalReport = (report) => {
const state = `${report.previousValue ? FLAG_VALUE_ON : FLAG_VALUE_OFF} → ${report.newValue ? FLAG_VALUE_ON : FLAG_VALUE_OFF}`
const lines = [
`[ops:global] modo: ${report.mode} | alvo: ${report.target}`,
`[ops:global] ${report.slug}.${report.field}: ${state}${report.changed ? '' : ' (sem mudança)'}`,
]
if (report.mode === 'plan') {
lines.push('[ops:global] plano — nenhuma escrita; use --apply para gravar e revalidar.')
}
if (report.write === 'applied') {
lines.push('[ops:global] global atualizado pelo Local API.')
}
if (report.write === 'skipped') {
lines.push(
'[ops:global] escrita pulada (valor já era o pedido) — cache revalidado mesmo assim.',
)
}
if (report.revalidation) {
lines.push(
`[ops:global] revalidação de ${report.revalidation.tag}: ${
report.revalidation.ok ? 'ok' : `falhou (${report.revalidation.reason})`
}`,
)
}
if (report.mode === 'verify') {
lines.push(
report.changed
? '[ops:global] estado diverge do valor pedido (exit 1).'
: '[ops:global] estado confere com o valor pedido.',
)
}
return lines
}

View file

@ -96,6 +96,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/markdown-ansi.mjs',
'scripts/lib/mediaBinaries.mjs',
'scripts/lib/mediaEndpoint.mjs',
'scripts/lib/opsGlobal.mjs',
'scripts/lib/plansOnlyClosesGuard.mjs',
'scripts/lib/playwright-e2e-args.mjs',
'scripts/lib/portalTransparenciaEmendas.mjs',

258
scripts/ops-global.mjs Normal file
View file

@ -0,0 +1,258 @@
/**
* C247 — ops CLI to flip an operational public global outside the admin:
*
* pnpm ops:global photoAlbum --selfie-search on
*
* Why this exists: the global's `afterChange` revalidates its `unstable_cache`
* tag only inside the deployed Next process, so a CLI/DB write left the public
* cache frozen and the ops had to click the admin to open/close the selfie
* search (C242 friction). This CLI writes through the Local API and busts the
* tag on the live server through the allowlisted `POST /api/revalidate`.
*
* Modes:
* (sem flag) plan/dry-run (default) — previous → new; no write
* --apply writes the single field and busts the tag;
* requires OPS_GLOBAL_CONFIRM=1 outside local
* dev and TEQO_ENV matching the exact database
* --verify rereads the flag; exit 1 when it diverges
*
* The write surface is the closed table in `scripts/lib/opsGlobal.mjs` — a
* flag only becomes writable by editing it there. The receipt records the
* previous/new value and the rollback (the same command with the previous
* value); the endpoint secret never enters the receipt or the log.
*
* Runbook recipe: `docs/ops/teqo-1313-deploy.md` (C247).
*/
import { join } from 'node:path'
import { getPayload } from 'payload'
import { isArchivePhotoRemovalChannelUrl } from '../src/lib/archivePhotoCatalog.ts'
import { getGlobalCacheTag } from '../src/utilities/globals.ts'
import { resolveRevalidateTag } from '../src/utilities/revalidateRequest.ts'
import {
TEQO_ENV_DATABASE_BY_ENV,
assertEnvironmentDatabaseTarget,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
loadCliEnv,
writeRepoFile,
} from './lib/cli.mjs'
import {
OPS_GLOBAL_DEFAULT_OUT_DIR,
OPS_GLOBAL_WRITE_CONFIRM_FLAG,
buildOpsGlobalWrite,
formatOpsGlobalCommand,
formatOpsGlobalOperations,
formatOpsGlobalRecoveryCommand,
formatOpsGlobalReport,
opsGlobalReportStamp,
parseOpsGlobalCliArgs,
} from './lib/opsGlobal.mjs'
loadCliEnv()
const die = dieWithLabel('ops:global')
const BUST_TIMEOUT_MS = 15_000
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
.map(([environment, database]) => `${environment} (${database})`)
.join(' | ')
const HELP = `
Uso: pnpm ops:global <global> --<flag> <on|off> [opções]
Liga/desliga uma flag operacional de global público pela CLI, gravando pelo
Local API e revalidando a tag no servidor vivo (sem restart e sem admin).
Modos:
(sem flag) plano/dry-run — valor anterior → novo; sem escrita
--apply grava e revalida (exige ${OPS_GLOBAL_WRITE_CONFIRM_FLAG}=1 fora do dev local)
--verify relê a flag; exit 1 se divergir do valor pedido
Operações (tabela fechada):
${formatOpsGlobalOperations().join('\n')}
Ambiente:
DATABASE_URL alvo (obrigatório em todos os modos)
NEXT_PUBLIC_SITE_URL / REVALIDATE_SECRET exigidos no --apply (alvo da revalidação)
--apply em alvo não-local/produção exige ${OPS_GLOBAL_WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines}
casando o banco exato. Plano e verify são read-only.
`
const writeReport = async (report) => {
const stamp = opsGlobalReportStamp(report.runAt)
const relativePath = join(OPS_GLOBAL_DEFAULT_OUT_DIR, 'reports', `ops-global-${stamp}.json`)
await writeRepoFile({
label: 'ops:global',
root: process.cwd(),
relativePath,
body: JSON.stringify(report, null, 2),
})
return join(process.cwd(), relativePath)
}
/**
* Bust the global's tag on the live server; a failure is returned, never
* silent. The same shape as the Instagram import helper — except here the
* caller fails the run on `ok: false`, because the bust is the outcome.
*/
const revalidateGlobalOnServer = async (tag) => {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/+$/, '')
const secret = process.env.REVALIDATE_SECRET?.trim()
if (!baseUrl || !secret) {
return { ok: false, tag, reason: 'NEXT_PUBLIC_SITE_URL/REVALIDATE_SECRET ausentes' }
}
try {
const response = await fetch(`${baseUrl}/api/revalidate?tag=${encodeURIComponent(tag)}`, {
method: 'POST',
headers: { 'x-revalidate-secret': secret },
signal: AbortSignal.timeout(BUST_TIMEOUT_MS),
})
return response.ok
? { ok: true, tag, reason: null }
: { ok: false, tag, reason: `HTTP ${response.status}` }
} catch (error) {
return {
ok: false,
tag,
reason: error instanceof Error && error.message !== '' ? error.message : 'falha de rede',
}
}
}
const printReport = (report, reportPath) => {
for (const line of formatOpsGlobalReport(report)) console.log(line)
console.log(`\n[ops:global] recibo JSON: ${reportPath}`)
}
async function main() {
const options = parseOpsGlobalCliArgs(process.argv.slice(2))
if (options.help) {
console.log(HELP)
process.exit(0)
}
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
const target = databaseTarget()
const tag = getGlobalCacheTag(options.slug)
console.log(`[ops:global] alvo: ${target} | modo: ${options.mode}`)
if (options.mode === 'apply') {
assertWriteConfirm({
label: 'ops:global',
flag: OPS_GLOBAL_WRITE_CONFIRM_FLAG,
command: formatOpsGlobalCommand(options.slug, options.operation, options.value),
})
assertEnvironmentDatabaseTarget()
if (!process.env.NEXT_PUBLIC_SITE_URL?.trim() || !process.env.REVALIDATE_SECRET?.trim()) {
die(
'NEXT_PUBLIC_SITE_URL/REVALIDATE_SECRET ausentes — sem eles não há como revalidar a tag no servidor vivo. Rode no ambiente do alvo (env file do stack).',
)
}
// Fail-closed BEFORE the write: a tag outside the endpoint allowlist would
// only surface after the global had already changed.
const allowed = resolveRevalidateTag(tag, null)
if (!allowed.ok) {
die(`tag "${tag}" fora da allowlist do /api/revalidate — ${allowed.error}.`)
}
}
const config = (await import('../src/payload.config.ts')).default
const payload = await getPayload({ config })
const startedAt = Date.now()
const currentDoc = await payload.findGlobal({
slug: options.slug,
depth: 0,
// Intentional bypass: the ops CLI is a trusted operator with no session.
overrideAccess: true,
})
const write = buildOpsGlobalWrite({
slug: options.slug,
operation: options.operation,
value: options.value,
currentDoc,
})
const baseReport = {
runAt: new Date().toISOString(),
mode: options.mode,
target,
slug: write.slug,
operation: write.operation,
field: write.field,
previousValue: write.previousValue,
newValue: write.newValue,
changed: write.changed,
rollbackCommand: write.rollbackCommand,
}
if (options.mode !== 'apply') {
const report = {
...baseReport,
write: 'none',
revalidation: null,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(report)
printReport(report, reportPath)
process.exit(options.mode === 'verify' && write.changed ? 1 : 0)
}
if (
write.changed &&
write.field === 'published' &&
write.newValue === true &&
!isArchivePhotoRemovalChannelUrl(currentDoc?.removalChannelUrl)
) {
die(
'canal de remoção não configurado no global Álbum de fotos — configure-o pelo admin antes de publicar (o hook fail-closed recusaria a escrita).',
)
}
if (write.changed) {
await payload.updateGlobal({
slug: options.slug,
data: write.data,
depth: 0,
// Intentional bypass: same trusted operator.
overrideAccess: true,
})
}
const revalidation = await revalidateGlobalOnServer(tag)
const report = {
...baseReport,
write: write.changed ? 'applied' : 'skipped',
revalidation,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(report)
printReport(report, reportPath)
if (!revalidation.ok) {
die(
`global ${write.changed ? 'atualizado' : 'já estava no valor pedido'}, mas a revalidação da tag falhou (${revalidation.reason}).\n` +
`Recupere manualmente:\n ${formatOpsGlobalRecoveryCommand({
baseUrl: process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/+$/, '') ?? '',
tag,
})}\n` +
`Rollback: ${write.rollbackCommand}\n` +
`Recibo: ${reportPath}`,
)
}
process.exit(0)
}
main().catch((error) => {
die(error?.message || String(error))
})

View file

@ -7,6 +7,15 @@ export const REVALIDATE_POSTS_TAG = 'posts' as const
export const REVALIDATE_PRIVACY_POLICY_CACHE_TAG = getGlobalCacheTag('privacy-policy')
/**
* C247 — the `photoAlbum` global's tag (`/fotos`, `/fotos/encontre` and the
* home section cache under it). The ops CLI (`pnpm ops:global`) writes the
* global outside the deployed runtime, where the `afterChange` hook's
* `revalidateTag` cannot reach the live cache: it busts the tag through this
* endpoint. Derived from the same owner the global hook busts.
*/
export const REVALIDATE_PHOTO_ALBUM_CACHE_TAG = getGlobalCacheTag('photoAlbum')
/**
* C233 — the archive photos listing tag the public album (`/fotos`) caches
* under. Derived from the same owner the collection hook busts, so the runbook
@ -32,6 +41,7 @@ export const REVALIDATE_SOCIAL_FEED_TAG = 'social-feed' as const
const ALLOWED_REVALIDATE_TAGS = [
REVALIDATE_POSTS_TAG,
REVALIDATE_PRIVACY_POLICY_CACHE_TAG,
REVALIDATE_PHOTO_ALBUM_CACHE_TAG,
ELECTION_TSE_CACHE_TAG,
MUNICIPALITY_CATALOG_CACHE_TAG,
REVALIDATE_SOCIAL_FEED_TAG,

View file

@ -216,6 +216,53 @@ describe('public photo album reads (C233)', () => {
expect(await getApprovedArchivePhotoById(photo.id)).toBeNull()
})
it('keeps the single-field global update safe — the ops CLI path (C247)', async () => {
// `pnpm ops:global` writes ONE field at a time. Payload merges the
// original doc into the global `beforeValidate`, so the fail-closed channel
// guard still sees the stored value and the sibling fields survive.
await setChannel(CHANNEL)
const updated = await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { selfieSearchEnabled: true },
overrideAccess: true,
}),
)
expect(updated.selfieSearchEnabled).toBe(true)
expect(updated.published).toBe(true)
expect(updated.removalChannelUrl).toBe(CHANNEL)
// The partial write alone cannot open the album without a stored channel:
// the same fail-closed guard refuses it.
await closeAlbumAndClearChannel()
await expect(
withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true },
overrideAccess: true,
}),
),
).rejects.toThrow()
const closed = await payload.findGlobal({
slug: 'photoAlbum',
depth: 0,
overrideAccess: true,
})
expect(closed.published).toBe(false)
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { selfieSearchEnabled: false },
overrideAccess: true,
}),
)
// Restore the beforeAll state the sibling specs rely on.
await setChannel(CHANNEL)
})
it('snapshots the município name/slug on the row so the public read never touches the campaign-only collection', async () => {
const photo = await createPhoto()
const municipality = await payload.find({

View file

@ -0,0 +1,228 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
OPS_GLOBAL_TARGETS,
buildOpsGlobalWrite,
formatOpsGlobalOperations,
formatOpsGlobalRecoveryCommand,
formatOpsGlobalReport,
opsGlobalReportStamp,
parseOpsGlobalCliArgs,
} from '../../scripts/lib/opsGlobal.mjs'
// C247 — the pure parser/plan of `pnpm ops:global`: the closed write surface,
// the dry-run default, the previous→new plan with rollback and the receipt
// helpers (the recovery recipe never carries the secret value).
describe('OPS_GLOBAL_TARGETS', () => {
it('is a closed table: photoAlbum with the two operational flags only', () => {
expect(Object.keys(OPS_GLOBAL_TARGETS)).toEqual(['photoAlbum'])
expect(Object.keys(OPS_GLOBAL_TARGETS.photoAlbum.flags)).toEqual(['selfie-search', 'published'])
})
it('generates the HELP operation block from the table', () => {
const lines = formatOpsGlobalOperations().join('\n')
expect(lines).toContain('photoAlbum --selfie-search <on|off>')
expect(lines).toContain('photoAlbum --published <on|off>')
expect(lines).toContain('Busca por selfie')
})
})
describe('parseOpsGlobalCliArgs', () => {
it('defaults to the plan mode and parses the operation', () => {
expect(parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search', 'on'])).toEqual({
slug: 'photoAlbum',
operation: 'selfie-search',
value: true,
mode: 'plan',
help: false,
})
expect(parseOpsGlobalCliArgs(['photoAlbum', '--published', 'off', '--apply'])).toEqual({
slug: 'photoAlbum',
operation: 'published',
value: false,
mode: 'apply',
help: false,
})
expect(
parseOpsGlobalCliArgs(['--verify', 'photoAlbum', '--selfie-search', 'off']),
).toMatchObject({
operation: 'selfie-search',
value: false,
mode: 'verify',
})
expect(parseOpsGlobalCliArgs(['--help']).help).toBe(true)
})
it('fails closed on unknown slug/flag/value, missing value and mode conflicts', () => {
expect(() => parseOpsGlobalCliArgs([])).toThrow(/informe o global/)
expect(() => parseOpsGlobalCliArgs(['homePage', '--selfie-search', 'on'])).toThrow(
/fora da tabela/,
)
expect(() => parseOpsGlobalCliArgs(['photoAlbum', '--nope', 'on'])).toThrow(/fora da tabela/)
expect(() => parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search', 'maybe'])).toThrow(
/valor inválido/,
)
expect(() => parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search'])).toThrow(/faltou valor/)
expect(() => parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search', 'on', 'extra'])).toThrow(
/posicional desconhecido/,
)
expect(() =>
parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search', 'on', '--published', 'off']),
).toThrow(/mais de uma operação/)
expect(() => parseOpsGlobalCliArgs(['photoAlbum'])).toThrow(/informe a operação/)
expect(() =>
parseOpsGlobalCliArgs(['photoAlbum', '--selfie-search', 'on', '--apply', '--verify']),
).toThrow(/mutuamente exclusivos/)
})
})
describe('buildOpsGlobalWrite', () => {
it('plans a single-field write with the previous value and the rollback command', () => {
const fromAbsent = buildOpsGlobalWrite({
slug: 'photoAlbum',
operation: 'selfie-search',
value: true,
currentDoc: {},
})
expect(fromAbsent).toMatchObject({
field: 'selfieSearchEnabled',
previousValue: false,
newValue: true,
changed: true,
data: { selfieSearchEnabled: true },
rollbackCommand: 'pnpm ops:global photoAlbum --selfie-search off --apply',
})
const fromFalse = buildOpsGlobalWrite({
slug: 'photoAlbum',
operation: 'published',
value: true,
currentDoc: { published: false },
})
expect(fromFalse).toMatchObject({
previousValue: false,
changed: true,
rollbackCommand: 'pnpm ops:global photoAlbum --published off --apply',
})
})
it('reports no change when the flag already holds the requested value', () => {
const write = buildOpsGlobalWrite({
slug: 'photoAlbum',
operation: 'published',
value: true,
currentDoc: { published: true },
})
expect(write.changed).toBe(false)
expect(write.data).toEqual({ published: true })
})
it('falls back to the table default when the row/slot is absent', () => {
// `published` defaults to true in the global; an absent row is an open album.
const write = buildOpsGlobalWrite({
slug: 'photoAlbum',
operation: 'published',
value: true,
currentDoc: null,
})
expect(write.previousValue).toBe(true)
expect(write.changed).toBe(false)
})
it('rolls back to on when the previous value was true, and refuses unknown operations', () => {
const write = buildOpsGlobalWrite({
slug: 'photoAlbum',
operation: 'published',
value: false,
currentDoc: { published: true },
})
expect(write.rollbackCommand).toBe('pnpm ops:global photoAlbum --published on --apply')
expect(() =>
buildOpsGlobalWrite({
slug: 'photoAlbum',
operation: 'nope',
value: true,
currentDoc: {},
}),
).toThrow(/fora da tabela/)
})
})
describe('receipt helpers', () => {
it('stamps the receipt name like the other CLIs', () => {
expect(opsGlobalReportStamp('2026-10-01T04:09:04.123Z')).toBe('2026-10-01T04-09-04-123Z')
})
it('builds the recovery command without ever printing the secret value', () => {
const command = formatOpsGlobalRecoveryCommand({
baseUrl: 'https://jorgesolla1313.com.br',
tag: 'global_photoAlbum',
})
expect(command).toContain('/api/revalidate?tag=global_photoAlbum')
expect(command).toContain('x-revalidate-secret: $REVALIDATE_SECRET')
})
it('prints the plan line and the divergence verdict', () => {
const plan = formatOpsGlobalReport({
mode: 'plan',
target: 'host/db',
slug: 'photoAlbum',
field: 'selfieSearchEnabled',
previousValue: false,
newValue: true,
changed: true,
write: 'none',
revalidation: null,
}).join('\n')
expect(plan).toContain('modo: plan')
expect(plan).toContain('photoAlbum.selfieSearchEnabled: off → on')
expect(plan).toContain('use --apply')
const verify = formatOpsGlobalReport({
mode: 'verify',
target: 'host/db',
slug: 'photoAlbum',
field: 'selfieSearchEnabled',
previousValue: false,
newValue: true,
changed: true,
write: 'none',
revalidation: null,
}).join('\n')
expect(verify).toContain('diverge do valor pedido')
})
it('distinguishes the applied and skipped writes and the bust outcome', () => {
const applied = formatOpsGlobalReport({
mode: 'apply',
target: 'host/db',
slug: 'photoAlbum',
field: 'selfieSearchEnabled',
previousValue: false,
newValue: true,
changed: true,
write: 'applied',
revalidation: { ok: true, tag: 'global_photoAlbum', reason: null },
}).join('\n')
expect(applied).toContain('global atualizado pelo Local API.')
expect(applied).toContain('revalidação de global_photoAlbum: ok')
const skipped = formatOpsGlobalReport({
mode: 'apply',
target: 'host/db',
slug: 'photoAlbum',
field: 'selfieSearchEnabled',
previousValue: true,
newValue: true,
changed: false,
write: 'skipped',
revalidation: { ok: false, tag: 'global_photoAlbum', reason: 'HTTP 401' },
}).join('\n')
expect(skipped).toContain('escrita pulada')
expect(skipped).toContain('falhou (HTTP 401)')
})
})

View file

@ -6,6 +6,7 @@ import { getGlobalCacheTag } from '@/utilities/globals'
import { MUNICIPALITY_CATALOG_CACHE_TAG } from '@/utilities/municipality/municipalityCatalogCache'
import {
REVALIDATE_CONTENT_PIECES_TAG,
REVALIDATE_PHOTO_ALBUM_CACHE_TAG,
REVALIDATE_POSTS_TAG,
REVALIDATE_PRIVACY_POLICY_CACHE_TAG,
resolveRevalidateTag,
@ -37,6 +38,11 @@ describe('resolveRevalidateTag', () => {
ok: true,
tag: REVALIDATE_CONTENT_PIECES_TAG,
})
// C247 — the ops CLI busts the photo album global it wrote outside Next.
expect(resolveRevalidateTag(REVALIDATE_PHOTO_ALBUM_CACHE_TAG, null)).toEqual({
ok: true,
tag: REVALIDATE_PHOTO_ALBUM_CACHE_TAG,
})
})
it('prefers query tag over body tag', () => {
@ -59,4 +65,8 @@ describe('resolveRevalidateTag', () => {
it('maps privacy-policy global slug to the shared cache tag helper', () => {
expect(REVALIDATE_PRIVACY_POLICY_CACHE_TAG).toBe(getGlobalCacheTag('privacy-policy'))
})
it('maps the photo album global slug to the shared cache tag helper (C247)', () => {
expect(REVALIDATE_PHOTO_ALBUM_CACHE_TAG).toBe(getGlobalCacheTag('photoAlbum'))
})
})