fix(admin): restaura S3ClientUploadHandler no importMap — admin em branco em prod (4ª recorrência da classe OPS69)

O importMap commitado perdeu a entrada do handler S3 no 1c865bd8
(regenerado sem as envs S3_*): com S3_* em prod, getFromImportMap
retorna undefined e a raiz do Payload não monta — tela branca, zero
erros de console, invisível no loop local (storage local em dev/test).

Fix da causa: regeneração com S3_* dummy (+2 linhas). Prevenção no
mesmo PR — o guard planejado no OPS69 (depends #77/#87) que nunca
existiu: pin unit, guard CI+deploy que regenera com envs dummy e falha
em divergência, wrapper do generate:importmap que injeta as envs, e
contrato compartilhado da chave. Post-mortem + changelog incluídos.
This commit is contained in:
Francisco Solla 2026-08-26 20:12:02 -03:00
parent 8dedf1028c
commit fea6c64514
11 changed files with 294 additions and 2 deletions

View file

@ -132,6 +132,8 @@ jobs:
GITHUB_BASE_REF: ${{ github.base_ref }}
PR_BODY: ${{ github.event.pull_request.body }}
run: node scripts/check-plans-only-pr-closes.mjs
- name: ImportMap keeps the S3 upload handler
run: node scripts/check-importmap-s3.mjs
- name: Check test file locations
run: node scripts/check-test-locations.mjs

View file

@ -73,6 +73,8 @@ jobs:
- name: Check test file locations
run: node scripts/check-test-locations.mjs
- name: ImportMap keeps the S3 upload handler
run: node scripts/check-importmap-s3.mjs
- name: Lint
run: pnpm lint
- name: Format check

View file

@ -49,7 +49,7 @@ These are product/architecture constraints — not a ban on editing existing cod
### Code Validation
- To validate typescript correctness after modifying code run `tsc --noEmit`
- Generate import maps after creating or modifying components.
- Generate import maps after creating or modifying components. **Always set the 4 `S3_*` envs (dummy values ok) when running `pnpm generate:importmap`** — without them the generator orphans `@payloadcms/storage-s3/client#S3ClientUploadHandler` and the admin goes blank in production (class OPS69/OPS72/OPS73; `scripts/check-importmap-s3.mjs` + the `importMapS3UploadHandler` unit pin guard this in CI).
- Any change to a collection/global/field schema requires a migration: `pnpm migrate:create <name>`, then `pnpm migrate` locally. Never rely on `push` (it is `false`). See the `payload-migrations` skill.
## Project Structure

View file

@ -0,0 +1 @@
**Admin em branco — importMap órfão do handler S3, 4ª recorrência (2026-08-26):** o admin do Payload em produção (jorgesolla1313.com.br/admin) abria em TELA BRANCA (login e dashboard) — zero erros de console no browser, SSR 200, assets 200; detecção por relato humano. Causa: o `src/app/(payload)/admin/importMap.js` commitado perdeu a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` no commit 1c865bd8 (2026-08-23, refactor "E1 simplify"), que regenerou o importMap sem as envs `S3_*`; com as envs setadas em prod (plugin s3Storage condicional em src/payload.config.ts, resolvedor src/utilities/mediaStorage.ts), `getFromImportMap` retorna undefined e a raiz do Payload não monta — o erro sai como console.error no log do SERVIDOR, invisível no browser; dev/test/CI sem `S3_*` usam storage local e o importMap órfão é invisível no loop local (e2e do admin passa), e o smoke do deploy só checa HTTP 200. Classe recorrente: OPS69/OPS72/OPS73 (4ª vez); o guard de CI planejado nas lições do OPS69 (depends #77/#87) nunca foi implementado. Fix causa: regeneração do importMap com `S3_*` dummy → diff de exatamente +2 linhas (import + entrada); sem migration/access/UI. Prevenção implementada no mesmo PR: pin unit `tests/unit/importMapS3UploadHandler.unit.spec.ts` (vermelho sem o fix, verde com); guard `scripts/check-importmap-s3.mjs` (regenera com envs dummy, exit 1 com drift + restore se divergir) ligado no ci-pr.yml (content guards) e no job verify do deploy.yml; wrapper `scripts/generate-importmap.mjs` no `pnpm generate:importmap` (injeta `S3_*` dummy quando ausentes — regeneração local nunca mais orfa o handler); contrato compartilhado `scripts/lib/importMapContract.mjs`; AGENTS.md atualizado. Verificação (prova do verificador 6/6): regressão e guard vermelho/verde provados, unit completa 284 arquivos/2682 testes verdes, e2e original com S3\_\* dummy (PORT 4093) monta login+dashboard com 0 `getFromImportMap` e 0 erros de console, tsc/lint exit 0, gate:fast verde. Prevenção documentada (não implementada): gerar o importMap no build (BuildKit secrets) em vez de commitá-lo, s3Storage incondicional no config, smoke de deploy headless que asserta o mount da raiz Payload, e fail-fast estruturado quando getFromImportMap retorna undefined. Deploy PENDENTE (manual, workflow_dispatch do deploy.yml) + confirmação do humano em prod. Post-mortem: `docs/postmortems/2026-08-26-admin-branco-importmap-s3.md`.

View file

@ -0,0 +1,81 @@
# Post-mortem: admin do Payload em tela branca — importMap órfão do handler S3
> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente.
## Registro
| Campo | Valor |
| ------------------- | ---------------- |
| Data do post-mortem | 2026-08-26 |
| Severidade | crítica |
| Ambiente | prod |
| Issue(s) | sem Issue |
| PR do fix | — (em andamento) |
| Detectado por | humano |
## Timeline
| Momento | Data/hora | Evento |
| ------------------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Início provável | 2026-08-23 | commit 1c865bd8 (refactor "E1 simplify — pin loading/retry/stale-quirk no spec e tipo local") regenerou o importMap sem as envs S3\_\*, removendo a entrada do handler |
| Detecção | 2026-08-26 | relato do usuário: admin em branco em produção |
| Correção mergeada | em andamento | PR ainda sem número |
| Deploy | ainda não feito | dispatch manual do deploy.yml (só humano) |
| Verificado em prod | não | aguarda deploy + confirmação do humano |
## O bug
O admin do Payload em produção (jorgesolla1313.com.br/admin) abria em TELA BRANCA — tanto no login quanto no dashboard. Zero erros de console no browser, SSR HTML completo (200) e todos os assets 200, então não havia nenhum sinal visível no cliente. **Sintoma — não a causa.**
## Causa-raiz
O `src/app/(payload)/admin/importMap.js` commitado perdeu a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` — removida no commit 1c865bd8 (2026-08-23, refactor "E1 simplify — pin loading/retry/stale-quirk no spec e tipo local"), que regenerou o importMap SEM as envs S3\_\*.
5-whys:
1. **Por que a tela fica branca?** Porque a raiz do Payload não monta: `getFromImportMap` (node_modules/payload/dist/bin/generateImportMap/utilities/getFromImportMap.js) retorna undefined para o handler `S3ClientUploadHandler` que o config serializado do admin referencia.
2. **Por que o handler não é encontrado?** Porque o importMap commitado não tem mais a entrada — o mapa é um artefato estático, não gerado em runtime.
3. **Por que a entrada sumiu do arquivo?** Porque o commit 1c865bd8 regenerou o importMap com `pnpm generate:importmap` sem as envs `S3_*` no ambiente.
4. **Por que sem as envs a entrada some?** Porque o plugin s3Storage é condicional em src/payload.config.ts (resolvedor src/utilities/mediaStorage.ts): sem `S3_*` o storage é local (dev/test/CI) e o mapa gerado é consistente para essa config; com `S3_*` setadas (produção, media no Garage) o config serializado do admin referencia o handler — que o mapa sem envs não inclui.
5. **Por que ninguém viu antes do deploy?** Porque dev/test/CI não têm `S3_*` — o importMap órfão é invisível em todo o loop local (o e2e do admin passa), e o smoke test do deploy só checa HTTP 200 (tela branca passa). O erro real (`getFromImportMap: PayloadComponent not found in importMap`) vai para o log do SERVIDOR via console.error silencioso — o browser mostra zero erros. E o guard planejado nas lições do OPS69 (CI que regenera com envs dummy e falha se divergir, depends #77/#87) nunca foi implementado.
Classe recorrente: OPS69 (2026-08-19), OPS72 (2026-08-19), OPS73 (2026-08-20) — esta é a 4ª recorrência.
**Reprodução:** local, servindo o build com envs S3*\* dummy no runtime → /admin/login branco (hidden div de 17 bytes, form ausente) + log do servidor com `getFromImportMap: PayloadComponent not found in importMap`; sem S3*\* → login renderiza. A mesma assinatura de prod foi verificada por instrumentação (chunk pushes, r.m, flight data, headers, HTML brotli).
## Correção
Regeneração do importMap com envs S3\_\* dummy — o diff foi de exatamente +2 linhas (import + entrada do handler). Resolve a **causa** (o artefato volta a ser consistente com o config que produção serializa), não o sintoma. Sem migration, sem mudança de access, sem UI.
## Verificação
- Teste de regressão: `tests/unit/importMapS3UploadHandler.unit.spec.ts` — falha se a entrada sumir do arquivo; vermelho sem o fix, verde com
- Guard: `scripts/check-importmap-s3.mjs` — regenera com S3\_\* dummy e sai 1 (com drift e restore do arquivo) se o commitado divergir; exit 1 sem o fix, exit 0 com
- Suíte: unit completa 284 arquivos / 2682 testes verdes; `tsc --noEmit` e lint exit 0
- E2E original: admin servido com S3\_\* dummy (PORT 4093) — login renderiza (título "Login - Payload", form Email/Senha), login admin@test.invalid funciona, dashboard "Painel de Controle - Payload" monta, log do servidor com 0 `getFromImportMap`, 0 erros de console
- CI: gate:fast verde (284 arquivos, 2682 testes)
- Prod: não verificado — aguarda deploy manual + confirmação do humano
## Prevenção
| Estratégia | Custo | Estado |
| ------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------ |
| Pin unit `importMapS3UploadHandler.unit.spec.ts` (falha se a entrada sumir) | barata | implementada agora (mesmo PR) |
| Guard `scripts/check-importmap-s3.mjs` no ci-pr.yml (content guards) e no job verify do deploy.yml | barata | implementada agora (mesmo PR) |
| Wrapper `scripts/generate-importmap.mjs` no `pnpm generate:importmap` (injeta S3\_\* dummy quando ausentes) | barata | implementada agora (mesmo PR) |
| Contrato compartilhado `scripts/lib/importMapContract.mjs` (chave + prefixo do handler, chaves das envs S3) | barata | implementada agora (mesmo PR) |
| AGENTS.md: instrução das envs S3\_\* no generate:importmap + referência aos guards | barata | implementada agora (mesmo PR) |
| Gerar o importMap no BUILD (prebuild com S3\_\* via BuildKit secrets) em vez de commitá-lo | cara | documentada — não implementada neste fluxo |
| Tornar o plugin s3Storage incondicional no config (adaptador local em dev) | cara | documentada — não implementada neste fluxo |
| Smoke real de deploy: abrir /admin em headless e assertar o mount da raiz Payload (não só HTTP 200) | cara | documentada — não implementada neste fluxo |
| Fail-fast/erro estruturado quando getFromImportMap retorna undefined (hoje é console.error silencioso no log do servidor) | cara | documentada — não implementada neste fluxo |
**Estratégia implementada:** pin unit que falha se o handler sumir do importMap; guard de CI+deploy que regenera com S3\_\* dummy e falha em divergência (drift + restore do arquivo); wrapper do `generate:importmap` que injeta as envs dummy — a regeneração local nunca mais orfa o handler; contrato compartilhado da chave/prefixo e das envs; doc no AGENTS.md.
**Estratégia documentada (cara):** gerar o importMap no build (elimina a classe: artefato gerado não drift; toca o pipeline do homeserver); tornar o s3Storage incondicional (estabiliza o importMap; muda storage local e caminho de media); smoke de deploy que abre /admin em headless e asserta o mount da raiz Payload; fail-fast estruturado quando getFromImportMap retorna undefined.
## Lições
- **Padrão "silencioso só em prod":** o bug não tem NENHUM sinal no loop local — dev/test/CI rodam sem S3\_\*, o importMap órfão é consistente para a config local, o e2e do admin passa, e o erro real sai como console.error no log do servidor enquanto o browser mostra zero erros. Quarta recorrência da mesma classe (OPS69/OPS72/OPS73). Diferenças de config entre prod e o loop local (envs condicionais) precisam de um espelho artificial (envs dummy) em pelo menos um ponto do pipeline.
- **Lição registrada sem enforcement não é lição:** o guard de CI (regenerar com envs dummy e falhar em divergência) já estava escrito nas lições do OPS69 com depends #77/#87 — e nunca foi implementado. O custo de 3 recorrências seguidas é exatamente o custo de anotar e não executar. Lições pós-incidente deveriam virar Issue/guard no mesmo fluxo, não item de backlog.
- **Smoke test que só checa HTTP 200 não é smoke de UI:** tela branca devolve 200 com SSR completo — o smoke do deploy validava transporte, não aplicação. Para apps que dependem de mount client-side (admin do Payload), o smoke precisa abrir a página em headless e assertar a montagem da raiz, ou o contrato precisa ser o log do servidor sem o erro conhecido.

View file

@ -61,7 +61,7 @@
"changelog:build": "node scripts/build-changelog.mjs",
"changelog:read": "node scripts/build-changelog.mjs --stdout",
"changelog:check": "node scripts/build-changelog.mjs --check",
"generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap",
"generate:importmap": "node scripts/generate-importmap.mjs",
"generate:types": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:types",
"migrate": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate",
"migrate:create": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate:create",

View file

@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* Guard: the committed importMap must include the S3ClientUploadHandler entry
* (OPS69/OPS72/OPS73 class — regenerating the importMap without the S3_* envs
* silently orphans it, and the admin goes blank in production).
*
* Regenerates the importMap with dummy S3_* envs (same trick as the OPS69
* fix — the CLI iterates the config without a DB) and fails if the committed
* file drifts. The working-tree file is restored afterwards so a failure
* leaves the checkout untouched; the drift is printed in the error.
*/
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { S3_STORAGE_ENV_KEYS } from './lib/importMapContract.mjs'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const importMapPath = path.join(repoRoot, 'src/app/(payload)/admin/importMap.js')
if (!existsSync(importMapPath)) {
console.error(`importMap not found at ${importMapPath}`)
process.exit(1)
}
const committed = readFileSync(importMapPath, 'utf8')
const GUARD_S3_DUMMY = {
S3_BUCKET: 'guard-bucket',
S3_ENDPOINT: 'http://127.0.0.1:3900',
S3_ACCESS_KEY_ID: 'guard-access-key',
S3_SECRET_ACCESS_KEY: 'guard-secret-key',
}
const env = {
...process.env,
// Same NODE_OPTIONS as the package.json `generate:importmap` script —
// the payload CLI loads server-only modules.
NODE_OPTIONS: '--no-deprecation --conditions=react-server',
// Dummy S3_* envs gate the s3Storage plugin in (see mediaStorage.ts) so the
// generation is prod-faithful; the generator never connects to the bucket.
...Object.fromEntries(S3_STORAGE_ENV_KEYS.map((key) => [key, GUARD_S3_DUMMY[key]])),
// The payload config needs a secret and a database URL to build, even
// though the generator never connects (iterateConfig, no DB).
PAYLOAD_SECRET: process.env.PAYLOAD_SECRET || 'guard-secret',
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://guard:guard@127.0.0.1:1/guard',
}
try {
execFileSync('pnpm', ['exec', 'payload', 'generate:importmap'], {
cwd: repoRoot,
env,
stdio: 'pipe',
})
} catch (error) {
restore(committed)
console.error('guard-importmap: payload generate:importmap failed')
console.error(error.stderr?.toString() || error.message)
process.exit(1)
}
const generated = readFileSync(importMapPath, 'utf8')
if (generated !== committed) {
restore(committed)
console.error(
[
'guard-importmap: the committed importMap drifted from the generation',
'with S3_* envs set. Regenerate it with the S3_* envs (dummy values are',
'fine) and commit the result:',
'',
' S3_BUCKET=x S3_ENDPOINT=http://127.0.0.1:3900 \\',
' S3_ACCESS_KEY_ID=x S3_SECRET_ACCESS_KEY=x pnpm generate:importmap',
'',
'Drift (committed -> generated):',
].join('\n'),
)
const drift = diffLines(committed, generated)
console.error(drift)
process.exit(1)
}
// Minimal line diff (no external deps): removed lines prefixed with "-",
// added lines with "+".
function diffLines(before, after) {
const beforeLines = before.split('\n')
const afterLines = after.split('\n')
const out = []
let i = 0
let j = 0
while (i < beforeLines.length && j < afterLines.length) {
if (beforeLines[i] === afterLines[j]) {
i += 1
j += 1
} else {
const nextInBefore = beforeLines.indexOf(afterLines[j], i + 1)
const nextInAfter = afterLines.indexOf(beforeLines[i], j + 1)
if (nextInAfter === -1) {
out.push(`- ${beforeLines[i]}`)
i += 1
} else if (nextInBefore === -1) {
out.push(`+ ${afterLines[j]}`)
j += 1
} else if (nextInAfter - j <= nextInBefore - i) {
out.push(`+ ${afterLines[j]}`)
j += 1
} else {
out.push(`- ${beforeLines[i]}`)
i += 1
}
}
}
while (i < beforeLines.length) out.push(`- ${beforeLines[i++]}`)
while (j < afterLines.length) out.push(`+ ${afterLines[j++]}`)
return out.join('\n')
}
function restore(content) {
writeFileSync(importMapPath, content)
}

View file

@ -0,0 +1,35 @@
#!/usr/bin/env node
/**
* Wrapper for `payload generate:importmap` that always injects dummy S3_*
* envs (when unset) so the generated importMap carries the
* S3ClientUploadHandler entry — regenerating without the S3_* envs silently
* orphans it and the admin goes blank in production (class
* OPS69/OPS72/OPS73). Dummy values are fine: the generator only iterates the
* config, it never connects to the bucket.
*/
import { spawnSync } from 'node:child_process'
import { S3_STORAGE_ENV_KEYS } from './lib/importMapContract.mjs'
const DUMMY = {
S3_BUCKET: 'dev-dummy-bucket',
S3_ENDPOINT: 'http://127.0.0.1:3900',
S3_ACCESS_KEY_ID: 'dev-dummy-access-key',
S3_SECRET_ACCESS_KEY: 'dev-dummy-secret-key',
}
const env = {
...process.env,
// Same NODE_OPTIONS as before — the payload CLI loads server-only modules.
NODE_OPTIONS: '--no-deprecation --conditions=react-server',
...Object.fromEntries(
S3_STORAGE_ENV_KEYS.map((key) => [key, process.env[key]?.trim() || DUMMY[key]]),
),
}
const result = spawnSync('payload', ['generate:importmap'], {
stdio: 'inherit',
env,
})
process.exit(result.status ?? 1)

View file

@ -0,0 +1,20 @@
/**
* Contract shared by the importMap unit pin and the CI guard. The admin goes
* blank in production when the importMap loses the S3 upload handler entry
* (class OPS69/OPS72/OPS73 — the importMap regenerated without the S3_* envs).
*/
export const S3_UPLOAD_HANDLER_KEY = '@payloadcms/storage-s3/client#S3ClientUploadHandler'
export const S3_UPLOAD_HANDLER_IMPORT_PREFIX =
'import { S3ClientUploadHandler as S3ClientUploadHandler_'
/**
* Env keys that gate the s3Storage plugin (see src/utilities/mediaStorage.ts).
* Kept in sync by the `importMapContract` unit pin.
*/
export const S3_STORAGE_ENV_KEYS = [
'S3_BUCKET',
'S3_ENDPOINT',
'S3_ACCESS_KEY_ID',
'S3_SECRET_ACCESS_KEY',
]

View file

@ -36,6 +36,7 @@ import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from
import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { InstagramSyncStatusPanel as InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da } from '../../../components/admin/InstagramSyncStatusPanel'
import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker'
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
@ -78,6 +79,7 @@ export const importMap = {
"@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36,
"./components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel": InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da,
"./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0,
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
"@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}

View file

@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import {
S3_UPLOAD_HANDLER_IMPORT_PREFIX,
S3_UPLOAD_HANDLER_KEY,
} from '../../scripts/lib/importMapContract.mjs'
// The admin goes blank in production when this entry is missing: with the
// S3_* envs set, the media collection uses the s3Storage plugin and the admin
// config references this handler — getFromImportMap returns undefined, the
// Payload root never mounts, and the admin renders an empty page with zero
// console errors (class OPS69/OPS72/OPS73; orphaned again by 1c865bd8,
// 2026-08-23). Regenerating the importMap without the S3_* envs silently
// removes the entry — this pin fails the exact drift.
const importMapPath = path.resolve(__dirname, '../../src/app/(payload)/admin/importMap.js')
describe('importMap (admin)', () => {
it('keeps the S3ClientUploadHandler entry required by the prod S3 storage', () => {
const source = readFileSync(importMapPath, 'utf8')
// Import + map entry, like the generator emits (+2 lines in the file).
expect(source).toContain(S3_UPLOAD_HANDLER_IMPORT_PREFIX)
expect(source).toContain(`${S3_UPLOAD_HANDLER_KEY}": S3ClientUploadHandler_`)
})
})