diff --git a/.gitignore b/.gitignore
index df6db5a0..286cbaeb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/docs/plans/ops-toggle-globais-publicos-impl.md b/docs/plans/ops-toggle-globais-publicos-impl.md
new file mode 100644
index 00000000..2f167205
--- /dev/null
+++ b/docs/plans/ops-toggle-globais-publicos-impl.md
@@ -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
--selfie-search on [--apply|--verify]"] --> PLAN["tabela fechada (operação→campo)
plan default"]
+ PLAN --> GUARD["guardas: OPS_GLOBAL_CONFIRM
TEQO_ENV↔banco · preflight do bust"]
+ GUARD --> WRITE["payload.updateGlobal
campo único · overrideAccess"]
+ WRITE --> REC["recibo JSON: previous→new
+ rollback (sem segredo)"]
+ end
+ WRITE --> POST["POST /api/revalidate?tag=global_photoAlbum
x-revalidate-secret"]
+ POST --> EP["route.ts + revalidateRequest.ts
allowlist fechada"]
+ EP --> BUST["revalidateTag('global_photoAlbum')
no processo Next vivo"]
+ BUST --> SURF["/fotos · /fotos/encontre · home
≤60 s · sem restart"]
+ POST -. falha .-> ERR["erro alto + curl de recuperação
(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 =` 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=` 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-.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.
diff --git a/package.json b/package.json
index 7b89765b..1c80bfb2 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/lib/opsGlobal.mjs b/scripts/lib/opsGlobal.mjs
new file mode 100644
index 00000000..2f5a2ea9
--- /dev/null
+++ b/scripts/lib/opsGlobal.mjs
@@ -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 -- [--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} ${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 | 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
+}
diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs
index 12d49b1b..b9f1fa14 100644
--- a/scripts/lib/test-affected-core.mjs
+++ b/scripts/lib/test-affected-core.mjs
@@ -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',
diff --git a/scripts/ops-global.mjs b/scripts/ops-global.mjs
new file mode 100644
index 00000000..d7eba52c
--- /dev/null
+++ b/scripts/ops-global.mjs
@@ -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 -- [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))
+})
diff --git a/src/utilities/revalidateRequest.ts b/src/utilities/revalidateRequest.ts
index 3178faec..595d0c19 100644
--- a/src/utilities/revalidateRequest.ts
+++ b/src/utilities/revalidateRequest.ts
@@ -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,
diff --git a/tests/int/archivePhotoPublicRead.int.spec.ts b/tests/int/archivePhotoPublicRead.int.spec.ts
index 36afe422..4ac1d73c 100644
--- a/tests/int/archivePhotoPublicRead.int.spec.ts
+++ b/tests/int/archivePhotoPublicRead.int.spec.ts
@@ -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({
diff --git a/tests/unit/opsGlobal.unit.spec.ts b/tests/unit/opsGlobal.unit.spec.ts
new file mode 100644
index 00000000..847f0370
--- /dev/null
+++ b/tests/unit/opsGlobal.unit.spec.ts
@@ -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 ')
+ expect(lines).toContain('photoAlbum --published ')
+ 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)')
+ })
+})
diff --git a/tests/unit/revalidateRequest.unit.spec.ts b/tests/unit/revalidateRequest.unit.spec.ts
index 208d3502..bb0588ac 100644
--- a/tests/unit/revalidateRequest.unit.spec.ts
+++ b/tests/unit/revalidateRequest.unit.spec.ts
@@ -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'))
+ })
})