diff --git a/.cursor/cloud-setup.sh b/.cursor/cloud-setup.sh index 9a50046f..0443d439 100755 --- a/.cursor/cloud-setup.sh +++ b/.cursor/cloud-setup.sh @@ -15,7 +15,6 @@ bash "${SCRIPT_DIR}/ensure-postgres.sh" if [ ! -f .env ]; then sed \ -e 's/YOUR_SECRET_HERE/cursor-cloud-local-secret/' \ - -e 's/YOUR_BLOB_TOKEN_HERE/vercel_blob_rw_cloud01_localplaceholder01/' \ .env.example > .env echo "[cloud-setup] wrote .env from .env.example (local-only values)" fi diff --git a/.env.example b/.env.example index 2299af2a..a60c6298 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,20 @@ DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo PAYLOAD_SECRET=YOUR_SECRET_HERE -BLOB_READ_WRITE_TOKEN=YOUR_BLOB_TOKEN_HERE +# Media storage (OPS52) — Garage S3 (homeserver). When ANY of the four required +# envs (S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY) is set, +# ALL of them must be: a partial config aborts the boot with the exact missing +# names (fail-closed). When none is set, Payload falls back to local disk storage +# (dev/test). The bucket stays PRIVATE: the app proxies the files through its +# /media/file route (the plugin streams each object from Garage), so no public +# bucket/DNS is needed. Production: S3_BUCKET=teqo-media, +# S3_ENDPOINT=http://100.119.220.31:3900 (tailnet do homeserver). Dev may use its +# own bucket — never share the production bucket across environments. +S3_BUCKET= +S3_ENDPOINT= +S3_REGION=garage +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= # Canonical public origin used in absolute campaign invite links. In production # this must be an exact HTTPS non-local origin: no credentials, path other than # `/`, query, or fragment. Production ignores request headers and invite diff --git a/.env.test b/.env.test index b33b4884..dfe2fb19 100644 --- a/.env.test +++ b/.env.test @@ -11,5 +11,5 @@ # TEQO_TEST_DATABASE_URL=postgresql://teqo:teqo@127.0.0.1:5433/teqo_test DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test PAYLOAD_SECRET=test-only-secret-not-used-in-production -BLOB_READ_WRITE_TOKEN= +# No S3_* set → media storage falls back to local disk in tests. NEXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/AGENTS.md b/AGENTS.md index f34c1711..7ff4a793 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17)**: the `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; the remote script builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout, and recreates the container. Merge em `main` verde == site atualizado. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media still on Vercel Blob until OPS52 (covers render broken pre-existing — 40 media rows reference local `/api/media/file/...` paths with no files anywhere; OPS52 scope). Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. +- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17)**: the `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; the remote script builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout, and recreates the container. Merge em `main` verde == site atualizado. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint tailnet `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). Render of the 40 pre-existing media rows is still broken (they reference local `/api/media/file/...` paths with no files anywhere) — recovery is OPS52-media scope; the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. @@ -35,7 +35,7 @@ These are product/architecture constraints — not a ban on editing existing cod - `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit. - To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data). - `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. `db:start`/`db:stop` pin the compose project with `-p teqo`, so they always target that ONE container even from a worktree directory. **`db:start` is idempotent (OPS37):** with `teqo-postgres-1` up and healthy it is a no-op — a plain `compose up` from another worktree would recreate the shared container and kill every live dev/e2e connection (the compose file's relative `./docker/postgres/init` bind mount makes the config hash differ per worktree); apply a real config/image change on purpose with `pnpm db:start --force-recreate`. -- **Per-worktree environments (`pnpm worktree next`, 2026-08-08; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. **`next` claims BEFORE creating the worktree (OPS33, 2026-08-10):** the same queue/order and optimistic lock as `pnpm agent:claim` (shared `claimIssue` in `scripts/lib/agent-github.mjs`); a failed claim prints the reason and stops with no orphan worktree; `--issue ` claims the directed issue (`ready`) or **reopens** an already-claimed one (`in-progress`, no re-claim, worktree reuse stays idempotent); the claim brief is printed and the output warns "já claimada — não rodar `pnpm agent:claim`" (`plan`/`new`/`kill` never touch Issues; the pool's coordinated `claimIssueForPool` is untouched). `worktree next [--issue N] [--stay]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, capped at 999 with hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt` (dev) and `teqo_wt_test` (tests) created on the shared container with the full migration chain AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/Blob/VAPID from the main repo — plus the gitignored `.opencode/secrets/*` (e.g. `penpot-token`, referenced by the worktree's `opencode.json` MCP config), without which opencode refuses to launch ("bad file reference" for the dangling `{file:…}`). **`next`/`plan`/`new` print `cd ` as the last line BY DEFAULT** — the caller applies it (shell function `worktree()` in `.agents/shell/worktree.sh`, or the `/worktree` opencode command); `--stay` suppresses the cd, `--go` explicit stays accepted as a no-op (it was the old default), and `kill` ends with `cd
` so the shell never sits in a destroyed directory. **In the interactive terminal the shell function sets `TEQO_WORKTREE_TERMINAL=1` and `next`/`plan`/`new` additionally print a `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue "]` directive BEFORE the `cd` line, which the function executes after the cd (OPS26, 2026-08-10; OPS33: `next` sends `/work-issue --issue ` — the claimed issue — with the prompt value quoted, and the shell tokenizes the line via xargs, quote-aware, never eval; OPS31 for `plan`): `next` opens the opencode TUI in the worktree with `/work-issue --issue ` already sent (the OPS25 command runs the whole cycle; the skill reads the rest from the Issue), `plan` opens with `/plan-issue` already sent (the TUI lands straight in the planning flow), `new` opens with no prompt ("apenas conversar", no skill — the opencode CLI's `--prompt` always auto-submits, there is no prefill-without-submit; that stays an upstream-only feature request, annotated in the opencode proposition). The launch is without `exec` (exiting the TUI returns to the shell in the worktree, no auto-`kill`), `--stay` suppresses both the cd and the launch (the claim still happens), the presets are constants in `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, unit-tested), and **the `/worktree` opencode command never launches a TUI** (no marker — no nested TUI). `worktree plan [bag] [--stay]` (2026-08-08) is the sibling for the `/plan-issue` skill: **each invocation creates a DIFFERENT planning worktree** so parallel `/plan-issue` sessions never share one — branch `plans/plan-issue-` when a `bag` slug is given (suffixed `-2`/`-3` if that name is taken) or the next free sequential `plans/plan-issue-` when not (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`); the lowercase `plans/…` prefix deliberately never matches `next`'s uppercase-led `-`, so a later `next` never collides in branch or slot. `worktree new [bag] [--stay]` (OPS27, 2026-08-10) is the NEUTRAL sibling: a worktree with **no pre-defined function\*\* (explore an idea, chat, plan without registering anything) — also a DIFFERENT one per invocation, branch `work/` (suffixed `-2`/`-3` on collision) or the next free sequential `work/` (`WORK_BRANCH_PREFIX`/`workBranchName`, same `namespaceBranchName` core as `plan`); the lowercase `work/…` prefix collides with neither `next` nor `plan`, and the provisioning is identical with `purpose: 'new'` (env comment `gerado por pnpm worktree new`; the frozen env marker keeps its historical `…next` text). `kill` drops the worktree's generated databases best-effort. Tests read `.env.test.local` after `.env.test` (vitest.setup.ts / playwright.config.ts / db-doctor / gate-ci mirror the layering), and `assertTestDatabase` accepts `teqo_test` or `teqo__test` on localhost only — still fail-closed on remote. Pure derivation lives in `scripts/lib/worktree-env.mjs` (unit-tested). Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning — and still runs migrate + seed mínimo on those shared databases (same parity; the seed re-pins campaignGoals and 5 municipalities there, overwriting manual tweaks). +- **Per-worktree environments (`pnpm worktree next`, 2026-08-08; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. **`next` claims BEFORE creating the worktree (OPS33, 2026-08-10):** the same queue/order and optimistic lock as `pnpm agent:claim` (shared `claimIssue` in `scripts/lib/agent-github.mjs`); a failed claim prints the reason and stops with no orphan worktree; `--issue ` claims the directed issue (`ready`) or **reopens** an already-claimed one (`in-progress`, no re-claim, worktree reuse stays idempotent); the claim brief is printed and the output warns "já claimada — não rodar `pnpm agent:claim`" (`plan`/`new`/`kill` never touch Issues; the pool's coordinated `claimIssueForPool` is untouched). `worktree next [--issue N] [--stay]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, capped at 999 with hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt` (dev) and `teqo_wt_test` (tests) created on the shared container with the full migration chain AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/`S3_*` (all-or-nothing)/VAPID from the main repo — plus the gitignored `.opencode/secrets/*` (e.g. `penpot-token`, referenced by the worktree's `opencode.json` MCP config), without which opencode refuses to launch ("bad file reference" for the dangling `{file:…}`). **`next`/`plan`/`new` print `cd ` as the last line BY DEFAULT** — the caller applies it (shell function `worktree()` in `.agents/shell/worktree.sh`, or the `/worktree` opencode command); `--stay` suppresses the cd, `--go` explicit stays accepted as a no-op (it was the old default), and `kill` ends with `cd
` so the shell never sits in a destroyed directory. **In the interactive terminal the shell function sets `TEQO_WORKTREE_TERMINAL=1` and `next`/`plan`/`new` additionally print a `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue "]` directive BEFORE the `cd` line, which the function executes after the cd (OPS26, 2026-08-10; OPS33: `next` sends `/work-issue --issue ` — the claimed issue — with the prompt value quoted, and the shell tokenizes the line via xargs, quote-aware, never eval; OPS31 for `plan`): `next` opens the opencode TUI in the worktree with `/work-issue --issue ` already sent (the OPS25 command runs the whole cycle; the skill reads the rest from the Issue), `plan` opens with `/plan-issue` already sent (the TUI lands straight in the planning flow), `new` opens with no prompt ("apenas conversar", no skill — the opencode CLI's `--prompt` always auto-submits, there is no prefill-without-submit; that stays an upstream-only feature request, annotated in the opencode proposition). The launch is without `exec` (exiting the TUI returns to the shell in the worktree, no auto-`kill`), `--stay` suppresses both the cd and the launch (the claim still happens), the presets are constants in `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, unit-tested), and **the `/worktree` opencode command never launches a TUI** (no marker — no nested TUI). `worktree plan [bag] [--stay]` (2026-08-08) is the sibling for the `/plan-issue` skill: **each invocation creates a DIFFERENT planning worktree** so parallel `/plan-issue` sessions never share one — branch `plans/plan-issue-` when a `bag` slug is given (suffixed `-2`/`-3` if that name is taken) or the next free sequential `plans/plan-issue-` when not (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`); the lowercase `plans/…` prefix deliberately never matches `next`'s uppercase-led `-`, so a later `next` never collides in branch or slot. `worktree new [bag] [--stay]` (OPS27, 2026-08-10) is the NEUTRAL sibling: a worktree with **no pre-defined function\*\* (explore an idea, chat, plan without registering anything) — also a DIFFERENT one per invocation, branch `work/` (suffixed `-2`/`-3` on collision) or the next free sequential `work/` (`WORK_BRANCH_PREFIX`/`workBranchName`, same `namespaceBranchName` core as `plan`); the lowercase `work/…` prefix collides with neither `next` nor `plan`, and the provisioning is identical with `purpose: 'new'` (env comment `gerado por pnpm worktree new`; the frozen env marker keeps its historical `…next` text). `kill` drops the worktree's generated databases best-effort. Tests read `.env.test.local` after `.env.test` (vitest.setup.ts / playwright.config.ts / db-doctor / gate-ci mirror the layering), and `assertTestDatabase` accepts `teqo_test` or `teqo__test` on localhost only — still fail-closed on remote. Pure derivation lives in `scripts/lib/worktree-env.mjs` (unit-tested). Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning — and still runs migrate + seed mínimo on those shared databases (same parity; the seed re-pins campaignGoals and 5 municipalities there, overwriting manual tweaks). - To (re)populate news content, `pnpm db:seed:posts` fetches articles live from jorgesolla.com.br into the LOCAL db (same non-local `DATABASE_URL` guard as `pnpm dev`). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the production `posts` cache via `POST /api/revalidate`. See "Posts & Tags" below. - To (re)populate the TSE 2022 election baseline, `pnpm db:seed:tse` downloads TSE open data and imports the Bahia scope into the LOCAL db (same guard). Since the 2026-07-23 hardening, elections-tab reads are cached under the `election-tse` tag — after re-seeding a database the deployed app reads, bust it via `POST /api/revalidate?tag=election-tse`. See "Election baseline data (TSE 2022)" below. @@ -60,7 +60,7 @@ The public news/publications system is backed by two collections (both in the `P **Self-revalidation on admin edits.** Both collections have an `afterChange` hook. `post` busts its own document tag + the `posts` listing on every publish/update (skipping only the initial draft that the admin create view generates during render). `tag` is broader: on change it re-reads every post referencing that tag (as `category` or in `tags`, passing `req` for transaction safety) and revalidates each one plus the listing — so flipping `hidden` is reflected on the site immediately. -**Seeding news content (`pnpm db:seed:posts`).** `scripts/seed-posts.mjs` (loaded via `scripts/seed-loader.mjs`) fetches ~39 articles live from jorgesolla.com.br (WordPress REST API, with an HTML-crawl fallback), imports them as `type: 'noticia'` published posts, converts the WP HTML body to Payload Lexical, and uploads cover images to Vercel Blob. The taxonomy (categories + the `eleitoral` control tag, and the per-slug classification) is hardcoded in the script; it reads/writes the `hidden` flag. It is **idempotent by slug** (existing posts are skipped; tags/media are reused by slug/filename) and reuses the same non-local `DATABASE_URL` guard as `pnpm dev` (override only with `ALLOW_REMOTE_DB=true`). The Vercel Blob store is shared across environments, so before uploading a cover it deletes any orphan blob left under the same deterministic `.` key, keeping the seed re-runnable. +**Seeding news content (`pnpm db:seed:posts`).** `scripts/seed-posts.mjs` (loaded via `scripts/seed-loader.mjs`) fetches ~39 articles live from jorgesolla.com.br (WordPress REST API, with an HTML-crawl fallback), imports them as `type: 'noticia'` published posts, converts the WP HTML body to Payload Lexical, and uploads cover images through the configured media storage (Garage S3 when the `S3_*` envs are set — the adapter overwrites the deterministic `.` key natively, no pre-delete needed; local disk otherwise). The taxonomy (categories + the `eleitoral` control tag, and the per-slug classification) is hardcoded in the script; it reads/writes the `hidden` flag. It is **idempotent by slug** (existing posts are skipped; tags/media are reused by slug/filename) and reuses the same non-local `DATABASE_URL` guard as `pnpm dev` (override only with `ALLOW_REMOTE_DB=true`). ### Revalidating after a manual/direct DB change diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 176fdf45..4c74dab9 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **PUB5** — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com `aria-label` acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em `font-black` (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (`campaignHero.unit`, `campaignHome.unit`, `frontend.e2e`). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (`E2E_PROD=1`, `.next-e2e`), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). `/simplify` 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate. +**Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte. + **Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte. **Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- "`); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (`git ls-remote` de `main` == SHA do job, senão skip "stale"), `flock` de serialização, workspace `~/teqo-deploy` (clone do Forgejo local), `docker login localhost:5000` (REGISTRY_USER/PASSWORD de `~/stack/.env`), build dos stages `runner`+`migrator` no homeserver (`--network stack_default` para a geração estática alcançar o `postgres:5432` do compose; secrets de `~/stack/teqo-1313.env` via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup `docker-compose.yml.pre-`), **migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout**, `docker compose up -d`, espera do healthcheck e smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn `login-options` com body JSON, `POST /api/revalidate` com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + `up -d` da imagem anterior) + job vermelho. **Divergência deliberada da intenção (fato novo):** a intenção assumia o runner no homeserver; verificado (`infra-solla/STATE.md` + ao vivo) que o runner é a `workstation-runner` na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job `runs-on: host` na workstation + todo o deploy no homeserver via SSH", o que manteve **zero secrets novos no Forgejo** e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): `docs/ops/teqo-1313-deploy.md` (novo). Guarda estrutural nova: `tests/unit/deployScript.unit.spec.ts` (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla `STATE.md` anotado à parte. diff --git a/docs/changelog/2026-08-18-ops52.md b/docs/changelog/2026-08-18-ops52.md new file mode 100644 index 00000000..58c5f9e2 --- /dev/null +++ b/docs/changelog/2026-08-18-ops52.md @@ -0,0 +1 @@ +**Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte. diff --git a/docs/plans/ops52-blob-vercel-para-garage-impl.md b/docs/plans/ops52-blob-vercel-para-garage-impl.md new file mode 100644 index 00000000..e19bd7d2 --- /dev/null +++ b/docs/plans/ops52-blob-vercel-para-garage-impl.md @@ -0,0 +1,122 @@ +# Impl: Migrar a media de produção do Vercel Blob para o Garage S3 + +Status: aprovado (revisado 2026-08-18 — A→B: ver "Revisão em execução") +Atualizado em: 2026-08-18 +Issue: #3 (OPS52) +Intenção: docs/plans/ops52-blob-vercel-para-garage.md +Appetite restante: herdado (~1 dia) + +## Revisão em execução (2026-08-18, gate com o humano) + +**A premissa da opção A caiu:** o Garage v2.1.0 **não suporta acesso anônimo na API +S3** — não há `--public-read` no CLI (`bucket allow` exige key real; testei +`anonymous`/`anon` → `NoSuchAccessKey`) e o source confirma ("when we support +anonymous access in the future"). O único serving público é o website mode +(`s3_web` 3902 + root_domain + DNS/cert) — infra fora do apetite. + +**Revisão para B (aprovada pelo humano, "desde que funcione"):** bucket **privado**; +sem `disablePayloadAccessControl`, o plugin injeta o `staticHandler` do S3 nos +handlers da collection — o Payload serve `/media/file/` streamando o +objeto do Garage (headObject + getObject via SDK, range/etag-304 nativos). +`media.url` continua **relativa** (`/media/file/`) — o MESMO contrato que +as 40 rows já têm → a OPS52-media (#10) restaura os objetos e o site renderiza +sem reescrever banco. `S3_PUBLIC_URL` foi **removida** das envs (não gera mais URL +absoluta). Tráfego de mídia passa pelo container (irrisório: ~40 capas). + +**O corpo abaixo já reflete a opção B.** + +## Leitura da intenção + +- **Outcome:** media inteiramente no Garage S3 (uploads novos + seed), sem dependência do Vercel Blob; guardrail de que cada ambiente aponta para o bucket certo; upload sem credencial falha com erro claro, nunca grava em lugar errado. +- **O que NÃO negociar:** fail-closed sem credencial de storage; seed idempotente; URLs públicas estáveis (posts/emails antigos não podem quebrar por causa da troca). +- **O que reavaliar:** + - _"Objetos existentes migrados do Blob"_ — **falso por evidência**: o achado da OPS51 (documentado na Issue #10) é que as 40 rows de `media` têm URLs **relativas** (`/api/media/file/…`) e os arquivos não existem em lugar nenhum (404 até no Vercel atual). **Não há URLs do domínio do Blob embutidas no conteúdo publicado** → o anti-goal "manter dois storages vivos" e a questão "redirect do domínio antigo" **desaparecem**: não há nada a redirecionar. A recuperação dos arquivos é a Issue #10 (dependente desta); esta entrega deixa o storage pronto. + - _"Contrato de URL pública"_ — com a opção B, o contrato é o **mesmo de hoje**: `media.url` relativa (`/api/media/file/`), servida pelo proxy do Payload; o frontend (que usa `media.url` direto como `src` e `toAbsoluteUrl` para OG) continua funcionando sem reescrita. + - _"Store compartilhada entre ambientes"_ — hoje `BLOB_READ_WRITE_TOKEN` é compartilhado; o próprio prod (`~/stack/teqo-1313.env`) **não tem token nenhum** hoje. A troca resolve por construção: dev sem `S3_*` → storage local; com `S3_*` → bucket explícito por ambiente. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[admin upload] --> P[payload.create media] + P -->|sem S3_* envs| L[storage LOCAL
media/ dir, dev/test] + P -->|S3_* envs completas| S3[s3Storage plugin
@payloadcms/storage-s3] + S3 --> G[Garage homeserver
bucket teqo-media PRIVADO] + B[navegador] -->|GET /api/media/file/<file>| X[Payload staticHandler] + X -->|GetObject SDK| G + D[seed-posts] -->|payload.create| S3 + D -->|overwrite nativo do S3| I[idempotente por filename] +``` + +**Opções consideradas:** A (URL direta no bucket — inviável: Garage v2 sem acesso anônimo) | B (proxy do Payload via staticHandler do plugin — **adotada**) | C (adapter custom via `@payloadcms/plugin-cloud-storage`) +**Recomendação:** **B** — `s3Storage` do `@payloadcms/storage-s3` sem `disablePayloadAccessControl`: o plugin injeta o `staticHandler` que streama o objeto do Garage em `/media/file/` (range/etag-304 nativos), mantendo o contrato de URL relativa e o bucket privado. Zero infra nova (sem DNS/túnel/public-read). +**Rejeitadas:** A (impossível no Garage v2.1.0: sem acesso anônimo na API S3; website mode exigiria expor 3902 + root_domain + DNS/cert — infra fora do apetite); C (adapter custom reimplementa handleUpload/handleDelete/generateURL do zero — o plugin oficial já faz; custo sem ganho). + +### Componentes / mudanças + +- **`src/payload.config.ts`**: trocar `vercelBlobStorage` → `s3Storage` (plugin só incluído quando habilitado): + ```ts + ...(mediaStorage.enabled + ? [ + s3Storage({ + collections: { media: true }, // sem disablePayloadAccessControl → proxy + bucket: mediaStorage.bucket, + config: { + credentials: { accessKeyId, secretAccessKey }, + region: mediaStorage.region, // 'garage' (s3_region do garage.toml) + endpoint: mediaStorage.endpoint, + forcePathStyle: true, // path-style: endpoint// + }, + }), + ] + : []), + ``` + Sem `acl` (Garage não suporta ACL), sem `clientUploads`/`signedDownloads` (server uploads; bucket privado). +- **`src/utilities/mediaStorage.ts`** (novo — módulo puro, unit-testado): `resolveS3StorageEnv(env)` → `{ enabled, bucket, endpoint, region, accessKeyId, secretAccessKey }` ou **throw no boot** com a lista exata das envs faltantes quando a configuração é **parcial** (alguma `S3_*` presente, nem todas). Todas ausentes → `enabled: false` (dev/test usam storage local). Fail-closed: config parcial nunca sobe (nem endpoint default da AWS, nem bucket errado). +- **Env (`S3_*`)** — `.env.example`, `.env.test`, `scripts/worktree.mjs` (`s3EnvCopiedLines` all-or-nothing em `scripts/lib/worktree-env.mjs`, unit-testada) no lugar de `copy('BLOB_READ_WRITE_TOKEN')`: + - `S3_ENDPOINT` — prod `http://100.119.220.31:3900` (tailnet do homeserver); dev pode usar a mesma ou bucket próprio + - `S3_REGION` — `garage` (default; opcional) + - `S3_BUCKET` — prod `teqo-media`; dev/test: bucket próprio se quiser S3, senão vazio (storage local) + - `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` — key `teqo-key` já existente (read/write no bucket) +- **`scripts/seed-posts.mjs`**: remover `blobDel` (`@vercel/blob`) e o pre-delete — o `PutObject` do S3 **sobrescreve** o key determinístico `.` nativamente (o problema do `put` sem `allowOverwrite` era específico do Blob); idempotência = lookup por filename no DB (já existe) + overwrite do storage. +- **`package.json`**: `remove @payloadcms/storage-vercel-blob` + `@vercel/blob`; `add @payloadcms/storage-s3@3.82.0`. Rodar `pnpm generate:importmap` (desaparece `VercelBlobClientUploadHandler` do importMap). +- **`.cursor/cloud-setup.sh`**: remover o sed morto de `YOUR_BLOB_TOKEN_HERE` (o placeholder saiu do `.env.example`). +- **Migration:** nenhuma (config-only; `url` é virtual field do upload). +- **Access / Consent:** nada muda (`media.read` é público; `canManagePublishedContent` intocado). +- **UI:** N/A (Impeccable A). +- **AGENTS.md + docs**: atualizar as menções a Blob/media (contexto → resolvido; "Seeding news content" → upload via storage configurado, sem pre-delete; worktree copy `S3_*`). Entrada nova em `docs/changelog/2026-08-18-ops52.md` + `pnpm changelog:build`. + +### Estado real do Garage (verificado 18/08, read-only via API admin) + +- Bucket **`teqo-media` existe** (0 objects, criado 14/08) + key **`teqo-key`** com read/write nele. **Fica privado** (nada de public-read — nem existe no v2). A secret key não está em nenhum `.env` do homeserver — recuperável via `GET /v0/key?id=` e vai para `~/stack/teqo-1313.env` no cutover. +- Túnel `garage.solla.dev` → localhost:3900 ativo, mas o **endpoint de uso é o tailnet** `http://100.119.220.31:3900` (tráfego de mídia não passa pelo túnel público). + +## Fases verificáveis + +1. **Core** — módulo `mediaStorage.ts` + unit tests; swap do plugin na config; `pnpm generate:importmap`; `pnpm exec tsc --noEmit` + `pnpm lint` + `pnpm format` + `pnpm exec knip` + `pnpm check:cycles`; `pnpm test` (unit + int). Verificar que dev sem envs sobe e que config parcial lança no boot. +2. **Seed + envs** — remover blobDel; `.env.example`/`.env.test`/`s3EnvCopiedLines` (lib + worktree.mjs) + testes; conferir idempotência. +3. **Prova de contrato (executada 18/08)** — upload/GET/DELETE de objeto probe via SDK S3 (SigV4) contra o tailnet; depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/` → 200 image/png (streaming do Garage); bucket devolvido a 0 objects e `.env.local` do worktree limpo das credenciais. +4. **Gates** — `pnpm gate:fast`; `pnpm build` local. + +## Rabbit holes / Não escopo (engenharia) + +- **Recuperar os 40 arquivos de media** — Issue #10 (OPS52-media), dependente; a forma final dela usa o storage desta entrega. +- **Excluir a store do Vercel Blob** — só após cutover estável (documentado, fora do merge). +- **Presigned URLs / ACL por objeto / CORS de clientUploads** — bucket privado + server uploads + proxy; nada disso aplica. +- **`teqo-media-dev`** — não criar por padrão: dev sem envs usa storage local (isolado por construção). Se um dev quiser S3 local, o `.env.example` documenta bucket próprio. +- **Migração de URLs no banco** — as 40 rows relativas são da Issue #10; nenhuma reescrita em massa aqui (e não há URLs Blob publicadas para redirecionar). + +## Riscos e mitigação + +- **Serving do proxy não funcionar com o Garage** → mitigação: fase 3 provou o contrato ponta a ponta (payload.create com S3 ativo → `GET /api/media/file/...` → 200) antes do merge; o `staticHandler` do plugin é o caminho padrão do Payload. +- **Garage rejeitar algo do SDK** (ex.: ACL, encoding de key) → sem `acl`; chaves são `.` simples; probe real executado na fase 3. +- **Boot de produção sem envs completas** → guard no `buildConfig` lança com a lista exata de faltantes; o runbook do deploy (OPS53 + `~/stack/teqo-1313.env`) seta as 5 `S3_*` (4 requeridas + região opcional) antes do rebuild. +- **Dev perdendo a habilidade de subir media** → melhoria, não regressão: hoje sem token o upload quebra; com `enabled: false` o dev grava no storage local e o admin local funciona. +- **Credenciais de prod em worktree** → `s3EnvCopiedLines` só copia all-or-nothing do main; dev deve manter bucket próprio (contrato do `.env.example`). + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (storage Garage + seed + fail-closed + ambientes separados) +- [ ] Invariantes AGENTS/engineering-standards (nada de access/Consent/transação tocado; naming em inglês; pt-BR só em strings de usuário) +- [ ] Testes de domínio: unit do `resolveS3StorageEnv` (ausente/parcial/completa) + unit do `s3EnvCopiedLines` (all-or-nothing) + unit do worktree (envs copiadas) +- [ ] Knip limpo (deps mortas removidas), importMap regenerado, docs atualizadas diff --git a/package.json b/package.json index 993efb45..73504084 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "@payloadcms/next": "3.82.0", "@payloadcms/plugin-import-export": "3.82.0", "@payloadcms/richtext-lexical": "3.82.0", - "@payloadcms/storage-vercel-blob": "3.82.0", + "@payloadcms/storage-s3": "3.82.0", "@payloadcms/ui": "3.82.0", "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", @@ -152,7 +152,6 @@ "@types/topojson-specification": "^1.0.5", "@types/web-push": "^3.6.4", "@types/yauzl": "^3.4.0", - "@vercel/blob": "2.3.1", "@vitejs/plugin-react": "4.5.2", "csv-parse": "^7.0.1", "eslint": "^9.39.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87ad1238..666daf56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,7 +44,7 @@ importers: '@payloadcms/richtext-lexical': specifier: 3.82.0 version: 3.82.0(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.82.0(@types/react@19.2.14)(@typescript/typescript6@6.0.2)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(@typescript/typescript6@6.0.2)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29) - '@payloadcms/storage-vercel-blob': + '@payloadcms/storage-s3': specifier: 3.82.0 version: 3.82.0(@types/react@19.2.14)(@typescript/typescript6@6.0.2)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@payloadcms/ui': @@ -210,9 +210,6 @@ importers: '@types/yauzl': specifier: ^3.4.0 version: 3.4.0 - '@vercel/blob': - specifier: 2.3.1 - version: 2.3.1 '@vitejs/plugin-react': specifier: 4.5.2 version: 4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.7.0)(lightningcss@1.30.2)(sass@1.77.4)(tsx@4.21.0)(yaml@2.9.0)) @@ -352,6 +349,88 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-sdk/checksums@3.1000.28': + resolution: {integrity: sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1112.0': + resolution: {integrity: sha512-E+Z+rIkExcUQbnV9EYfT/fOnveV9B8vIZUzE+wYCVMKgcn/aJJy73FvRf46SGd1NqgT8ESqNlk3Wc/9wE/7/Og==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.8': + resolution: {integrity: sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.69': + resolution: {integrity: sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.71': + resolution: {integrity: sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.14': + resolution: {integrity: sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.76': + resolution: {integrity: sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.80': + resolution: {integrity: sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.69': + resolution: {integrity: sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.13': + resolution: {integrity: sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.75': + resolution: {integrity: sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/lib-storage@3.1112.0': + resolution: {integrity: sha512-IjQd2q6fWjBX7Ipzj3h1gRjd5JVFPRAXGWJdxKGGaQ5XX2aks/O2ibfid/cCZ4IHQO/XQ9azWlzuHCPmjARfQQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@aws-sdk/client-s3': ^3.1112.0 + + '@aws-sdk/middleware-sdk-s3@3.972.74': + resolution: {integrity: sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.43': + resolution: {integrity: sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1112.0': + resolution: {integrity: sha512-Qmnhl9jpjJrp3Jei6yfnMPdhX/wLxM2kFdUr2V/9PpDhosRUIMfVTNfuq6Mq6WVHfGyLyeksoHt16agzJ50auA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.45': + resolution: {integrity: sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1111.0': + resolution: {integrity: sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.4': + resolution: {integrity: sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.39': + resolution: {integrity: sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -2102,8 +2181,8 @@ packages: react: ^19.0.1 || ^19.1.2 || ^19.2.1 react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1 - '@payloadcms/storage-vercel-blob@3.82.0': - resolution: {integrity: sha512-Ey8MvW8ZKl54wtCTh6CmoLU/Ov9oRA4NSICzEuxNrmSJfEnyllxG6GW2G0sx/0Ta7eO2xCWeGWTTnK17u5HHKw==} + '@payloadcms/storage-s3@3.82.0': + resolution: {integrity: sha512-zIQHvAKudXOq1l9/A+YZl9umiiNfAek/bCWzpaXPF0etJ7f+4a3mGmqzxitt/kpOidCgF3AxX37v7dk3CgeXWQ==} engines: {node: ^18.20.2 || >=20.9.0} peerDependencies: payload: 3.82.0 @@ -3031,6 +3110,30 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@smithy/core@3.33.2': + resolution: {integrity: sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.7.2': + resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.11.2': + resolution: {integrity: sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.2': + resolution: {integrity: sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.17.2': + resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3652,10 +3755,6 @@ packages: vue-router: optional: true - '@vercel/blob@2.3.1': - resolution: {integrity: sha512-6f9oWC+DbWxIgBLOdqjjn2/REpFrPDB7y5B5HA1ptYkzZaBgL6E34kWrptJvJ7teApJdbAs3I1a5A7z1y8SDHw==} - engines: {node: '>=20.0.0'} - '@vercel/oidc@3.2.0': resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} @@ -3891,9 +3990,6 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - async-retry@1.3.3: - resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} - atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -3996,6 +4092,9 @@ packages: body-scroll-lock@4.0.0-beta.0: resolution: {integrity: sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -4024,6 +4123,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.6.0: + resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -4885,6 +4987,10 @@ packages: events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -5394,10 +5500,6 @@ packages: is-buffer@1.1.6: resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} - is-buffer@2.0.5: - resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} - engines: {node: '>=4'} - is-bun-module@2.0.0: resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} @@ -6902,10 +7004,6 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - retry@0.13.1: - resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} - engines: {node: '>= 4'} - rettime@0.10.1: resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} @@ -7158,6 +7256,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + stream-to-array@2.3.0: resolution: {integrity: sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==} @@ -7562,10 +7663,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@6.27.0: - resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} - engines: {node: '>=18.17'} - undici@7.21.0: resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==} engines: {node: '>=20.18.1'} @@ -8058,6 +8155,190 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@aws-sdk/checksums@3.1000.28': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1112.0': + dependencies: + '@aws-sdk/checksums': 3.1000.28 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-node': 3.972.80 + '@aws-sdk/middleware-sdk-s3': 3.972.74 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/core@3.977.8': + dependencies: + '@aws-sdk/types': 3.974.4 + '@aws-sdk/xml-builder': 3.972.39 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.33.2 + '@smithy/signature-v4': 5.7.2 + '@smithy/types': 4.17.2 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.71': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.14': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-login': 3.972.76 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.76': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.80': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-ini': 3.973.14 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.13': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/token-providers': 3.1111.0 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.75': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/lib-storage@3.1112.0(@aws-sdk/client-s3@3.1112.0)': + dependencies: + '@aws-sdk/client-s3': 3.1112.0 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + buffer: 5.6.0 + events: 3.3.0 + stream-browserify: 3.0.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.74': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.43': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1112.0': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.45': + dependencies: + '@aws-sdk/types': 3.974.4 + '@smithy/signature-v4': 5.7.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1111.0': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.4': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.39': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -9746,10 +10027,12 @@ snapshots: - utf-8-validate - yjs - '@payloadcms/storage-vercel-blob@3.82.0(@types/react@19.2.14)(@typescript/typescript6@6.0.2)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@payloadcms/storage-s3@3.82.0(@types/react@19.2.14)(@typescript/typescript6@6.0.2)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: + '@aws-sdk/client-s3': 3.1112.0 + '@aws-sdk/lib-storage': 3.1112.0(@aws-sdk/client-s3@3.1112.0) + '@aws-sdk/s3-request-presigner': 3.1112.0 '@payloadcms/plugin-cloud-storage': 3.82.0(@types/react@19.2.14)(@typescript/typescript6@6.0.2)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@vercel/blob': 2.3.1 payload: 3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0) transitivePeerDependencies: - '@types/react' @@ -10768,6 +11051,39 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/core@3.33.2': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.7.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.11.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/types@4.17.2': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -11315,14 +11631,6 @@ snapshots: next: 15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) react: 19.2.4 - '@vercel/blob@2.3.1': - dependencies: - async-retry: 1.3.3 - is-buffer: 2.0.5 - is-node-process: 1.2.0 - throttleit: 2.1.0 - undici: 6.27.0 - '@vercel/oidc@3.2.0': {} '@vercel/speed-insights@1.3.1(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(react@19.2.4)': @@ -11597,10 +11905,6 @@ snapshots: async-function@1.0.0: {} - async-retry@1.3.3: - dependencies: - retry: 0.13.1 - atomic-sleep@1.0.0: {} available-typed-arrays@1.0.7: @@ -11688,6 +11992,8 @@ snapshots: body-scroll-lock@4.0.0-beta.0: {} + bowser@2.14.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -11719,6 +12025,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.6.0: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -12661,6 +12972,8 @@ snapshots: transitivePeerDependencies: - bare-abort-controller + events@3.3.0: {} + eventsource-parser@3.0.6: {} eventsource-parser@3.1.0: {} @@ -13231,8 +13544,6 @@ snapshots: is-buffer@1.1.6: {} - is-buffer@2.0.5: {} - is-bun-module@2.0.0: dependencies: semver: 7.7.4 @@ -15123,8 +15434,6 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - retry@0.13.1: {} - rettime@0.10.1: {} reusify@1.1.0: {} @@ -15526,6 +15835,11 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-to-array@2.3.0: dependencies: any-promise: 1.3.0 @@ -16000,8 +16314,6 @@ snapshots: undici-types@6.21.0: {} - undici@6.27.0: {} - undici@7.21.0: {} undici@7.24.4: {} diff --git a/scripts/lib/worktree-env.mjs b/scripts/lib/worktree-env.mjs index 7830662a..36af5b98 100644 --- a/scripts/lib/worktree-env.mjs +++ b/scripts/lib/worktree-env.mjs @@ -50,6 +50,33 @@ export const testDatabaseForSlot = (slot) => `teqo_wt${slot}_test` /** DB name charset guard — derived names are fed into `CREATE DATABASE` SQL. */ export const isGeneratedDatabaseName = (name) => /^teqo_wt[0-9]+(_test)?$/.test(name ?? '') +/** + * Media storage (OPS52) — the four required `S3_*` keys, mirrored from + * `resolveS3StorageEnv` in `src/utilities/mediaStorage.ts` (scripts cannot + * import the TS module). `S3_REGION` is optional (default `garage`). + */ +const S3_WORKTREE_REQUIRED_KEYS = [ + 'S3_BUCKET', + 'S3_ENDPOINT', + 'S3_ACCESS_KEY_ID', + 'S3_SECRET_ACCESS_KEY', +] + +/** + * `S3_*` lines to copy into a worktree env — ALL or NOTHING: a partial set + * would abort the worktree's boot (fail-closed `resolveS3StorageEnv`), so a + * partial main-env copies nothing and the worktree falls back to local disk + * storage. The dev is expected to keep the main env's bucket out of + * production (`.env.example` contract). + * + * @param {Record} env + * @returns {string[]} + */ +export const s3EnvCopiedLines = (env) => + S3_WORKTREE_REQUIRED_KEYS.every((key) => env[key]) + ? [...S3_WORKTREE_REQUIRED_KEYS, 'S3_REGION'].map((key) => `${key}=${env[key]}`) + : [] + /** * Final environment for a branch. `takenSlots` = slots already claimed by * OTHER live worktrees; bumps +1 until free (bounded — live slot sets are diff --git a/scripts/seed-posts.mjs b/scripts/seed-posts.mjs index beb7d38e..f0f088b6 100644 --- a/scripts/seed-posts.mjs +++ b/scripts/seed-posts.mjs @@ -24,7 +24,6 @@ * (against a remote DB, on purpose:) ALLOW_REMOTE_DB=true pnpm db:seed:posts */ import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical' -import { del as blobDel } from '@vercel/blob' import { JSDOM } from 'jsdom' import { getPayload } from 'payload' import { dieWithLabel, loadCliEnv } from './lib/cli.mjs' @@ -381,23 +380,12 @@ async function ensureCoverMedia(payload, { slug, coverUrl, alt }) { const buffer = Buffer.from(await res.arrayBuffer()) - // The Vercel Blob store is SHARED across environments, and the storage plugin - // (@payloadcms/storage-vercel-blob) calls `put()` without `allowOverwrite` and - // exposes no option to enable it. So a blob left behind under this deterministic - // key (`.`) by an earlier run in another environment would make the - // upload throw "This blob already exists". We have no DB media row for it here - // (checked above), so it's an orphan — delete it first so the upload is - // deterministic and this seed stays re-runnable. `del` is idempotent (no-op - // when the blob is absent). - const blobToken = process.env.BLOB_READ_WRITE_TOKEN - if (blobToken) { - try { - await blobDel(filename, { token: blobToken }) - } catch (err) { - console.warn(`[seed:posts] blob pre-delete skipped for ${filename}: ${err.message}`) - } - } - + // The upload goes through the configured media storage plugin: Garage S3 + // when the S3_* envs are set (OPS52), local disk otherwise. The S3 adapter + // overwrites the deterministic key (`.`) natively, so no + // pre-delete is needed — unlike Vercel Blob, whose `put()` refused + // overwrites and forced the cleanup dance this script used to do. The + // DB-side idempotency (lookup by filename above) is unchanged. const media = await payload.create({ collection: 'media', data: { alt: alt || slug }, diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index 57d13760..f973a332 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -129,6 +129,7 @@ import { DEV_PORT_BASE, GENERATED_ENV_MARKER, isGeneratedDatabaseName, + s3EnvCopiedLines, worktreeEnvFileContents, worktreeEnvironment, } from './lib/worktree-env.mjs' @@ -407,7 +408,8 @@ const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot, purpo env, payloadSecret, copiedLines: [ - ...copy('BLOB_READ_WRITE_TOKEN'), + // Media storage (OPS52): all-or-nothing (see s3EnvCopiedLines). + ...s3EnvCopiedLines(mainEnv), ...copy('NEXT_PUBLIC_VAPID_PUBLIC_KEY'), ...copy('VAPID_PUBLIC_KEY'), ...copy('VAPID_PRIVATE_KEY'), diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 1cf6f243..058ced46 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -34,7 +34,6 @@ import { ExportPreview as ExportPreview_cdf7e044479f899a31f804427d568b36 } from import { ExportSaveButton as ExportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' -import { VercelBlobClientUploadHandler as VercelBlobClientUploadHandler_16c82c5e25f430251a3e3ba57219ff4e } from '@payloadcms/storage-vercel-blob/client' import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' @@ -75,7 +74,6 @@ export const importMap = { "@payloadcms/plugin-import-export/rsc#ExportSaveButton": ExportSaveButton_cdf7e044479f899a31f804427d568b36, "@payloadcms/plugin-import-export/rsc#ImportPreview": ImportPreview_cdf7e044479f899a31f804427d568b36, "@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36, - "@payloadcms/storage-vercel-blob/client#VercelBlobClientUploadHandler": VercelBlobClientUploadHandler_16c82c5e25f430251a3e3ba57219ff4e, "@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/src/payload.config.ts b/src/payload.config.ts index 91297270..34717d57 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'url' import { resendAdapter } from '@payloadcms/email-resend' import { importExportPlugin } from '@payloadcms/plugin-import-export' -import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob' +import { s3Storage } from '@payloadcms/storage-s3' import { pt } from 'payload/i18n/pt' import { Activity } from './collections/Activity' import { AllocationDecision } from './collections/AllocationDecision' @@ -46,10 +46,13 @@ import { Metadata } from './globals/Metadata' import { PrivacyPolicy } from './globals/PrivacyPolicy' import { SiteSettings } from './globals/SiteSettings' import { isPayloadAdmin } from './utilities/campaignAccess' +import { resolveS3StorageEnv } from './utilities/mediaStorage' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) +const mediaStorage = resolveS3StorageEnv(process.env) + const campaignEmailFromAddress = process.env.CAMPAIGN_EMAIL_FROM?.trim() || 'campanha@jorgesolla.com.br' const campaignEmailFromName = process.env.CAMPAIGN_EMAIL_FROM_NAME?.trim() || 'Campanha Jorge Solla' @@ -144,12 +147,28 @@ export default buildConfig({ apiKey: process.env.RESEND_API_KEY || '', }), plugins: [ - vercelBlobStorage({ - collections: { - media: true, - }, - token: process.env.BLOB_READ_WRITE_TOKEN, - }), + // Media storage (OPS52): Garage S3 when the S3_* envs are set, local disk + // otherwise; a partial S3 config throws at boot (see mediaStorage.ts). The + // bucket stays private — Payload proxies files through /media/file. + ...(mediaStorage.enabled + ? [ + s3Storage({ + collections: { + media: true, + }, + bucket: mediaStorage.bucket, + config: { + credentials: { + accessKeyId: mediaStorage.accessKeyId, + secretAccessKey: mediaStorage.secretAccessKey, + }, + region: mediaStorage.region, + endpoint: mediaStorage.endpoint, + forcePathStyle: true, + }, + }), + ] + : []), importExportPlugin({ collections: [adminCsvExportCollection('signature'), adminCsvExportCollection('contact')], overrideExportCollection: withImportExportAdminAccess, diff --git a/src/utilities/mediaStorage.ts b/src/utilities/mediaStorage.ts new file mode 100644 index 00000000..6fea4ca5 --- /dev/null +++ b/src/utilities/mediaStorage.ts @@ -0,0 +1,63 @@ +/** + * Resolves the media storage configuration from the S3_* environment + * variables (Garage S3-compatible endpoint). + * + * Fail-closed contract (OPS52): + * - No S3_* var set -> { enabled: false } (dev/test use Payload's local storage) + * - Partially set -> throws with the exact list of missing vars (never boots + * against the wrong endpoint/bucket, never AWS defaults) + * - All set -> { enabled: true, ... } for the s3Storage plugin + * + * S3_REGION is optional: the Garage server (garage.toml) fixes `s3_region = + * "garage"`, which is the only accepted value for signature validation. + * + * Public URLs are NOT generated: the bucket stays private and Payload proxies + * the files through its /media/file route (the plugin's staticHandler streams + * the object from Garage), so `media.url` keeps the relative + * /media/file/ contract unchanged. + */ +export const S3_STORAGE_ENV_KEYS = [ + 'S3_BUCKET', + 'S3_ENDPOINT', + 'S3_ACCESS_KEY_ID', + 'S3_SECRET_ACCESS_KEY', +] as const + +const S3_REGION_DEFAULT = 'garage' + +export type S3StorageEnv = + | { enabled: false } + | { + enabled: true + bucket: string + endpoint: string + region: string + accessKeyId: string + secretAccessKey: string + } + +export function resolveS3StorageEnv(env: Record): S3StorageEnv { + const value = (key: string) => env[key]?.trim() || '' + + const present = S3_STORAGE_ENV_KEYS.filter((key) => value(key) !== '') + if (present.length === 0) { + return { enabled: false } + } + if (present.length < S3_STORAGE_ENV_KEYS.length) { + const missing = S3_STORAGE_ENV_KEYS.filter((key) => !present.includes(key)) + throw new Error( + `Media storage S3 configurado parcialmente: faltam ${missing.join( + ', ', + )}. Configure todas as envs S3_* (bucket, endpoint, credenciais) ou nenhuma (storage local).`, + ) + } + + return { + enabled: true, + bucket: value('S3_BUCKET'), + endpoint: value('S3_ENDPOINT'), + region: value('S3_REGION') || S3_REGION_DEFAULT, + accessKeyId: value('S3_ACCESS_KEY_ID'), + secretAccessKey: value('S3_SECRET_ACCESS_KEY'), + } +} diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 8e4d8b9b..53ddd82d 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -472,6 +472,9 @@ describe('src/utilities top-level is pinned', () => { 'hookFilledData.ts', 'leaderContactsPageData.ts', 'loadNamesByIds.ts', + // OPS52 — media storage env resolution; shared by payload.config.ts + // (fail-closed S3/Garage switch) and its unit spec. + 'mediaStorage.ts', 'onda0Provision.ts', 'organizationData.ts', 'payloadTransaction.ts', diff --git a/tests/unit/mediaStorage.unit.spec.ts b/tests/unit/mediaStorage.unit.spec.ts new file mode 100644 index 00000000..757befc4 --- /dev/null +++ b/tests/unit/mediaStorage.unit.spec.ts @@ -0,0 +1,56 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { resolveS3StorageEnv, S3_STORAGE_ENV_KEYS } from '../../src/utilities/mediaStorage' + +const fullEnv = (): Record => ({ + S3_BUCKET: 'teqo-media', + S3_ENDPOINT: 'http://100.119.220.31:3900', + S3_ACCESS_KEY_ID: 'AK-test', + S3_SECRET_ACCESS_KEY: 'secret', +}) + +describe('resolveS3StorageEnv (OPS52)', () => { + it('desabilita o storage S3 quando nenhuma env S3_* está presente', () => { + expect(resolveS3StorageEnv({})).toEqual({ enabled: false }) + expect(resolveS3StorageEnv({ DATABASE_URL: 'x', PAYLOAD_SECRET: 'y' })).toEqual({ + enabled: false, + }) + }) + + it('habilita o storage com todas as envs e region default do Garage', () => { + expect(resolveS3StorageEnv(fullEnv())).toEqual({ + enabled: true, + bucket: 'teqo-media', + endpoint: 'http://100.119.220.31:3900', + region: 'garage', + accessKeyId: 'AK-test', + secretAccessKey: 'secret', + }) + }) + + it('aceita S3_REGION explícita', () => { + const resolved = resolveS3StorageEnv({ ...fullEnv(), S3_REGION: 'garage-dev' }) + expect(resolved.enabled && resolved.region).toBe('garage-dev') + }) + + it('lança com a lista exata de envs faltantes na configuração parcial', () => { + const partial: Record = { ...fullEnv() } + delete partial.S3_SECRET_ACCESS_KEY + delete partial.S3_ENDPOINT + expect(() => resolveS3StorageEnv(partial)).toThrowError( + /faltam S3_ENDPOINT, S3_SECRET_ACCESS_KEY/, + ) + }) + + it('ignora envs setadas com valor em branco na contagem de parcial', () => { + const env = { ...fullEnv(), S3_BUCKET: ' ' } + expect(() => resolveS3StorageEnv(env)).toThrowError(/faltam S3_BUCKET/) + }) + + it('lista todas as envs esperadas como obrigatórias (exceto region)', () => { + expect(S3_STORAGE_ENV_KEYS).toHaveLength(4) + expect(S3_STORAGE_ENV_KEYS).not.toContain('S3_REGION') + }) +}) diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts index 8d743596..ab0fe2e9 100644 --- a/tests/unit/worktree.unit.spec.ts +++ b/tests/unit/worktree.unit.spec.ts @@ -9,6 +9,7 @@ import { hashSlotOf, isGeneratedDatabaseName, numericSlotOfCode, + s3EnvCopiedLines, testDatabaseForSlot, worktreeEnvFileContents, worktreeEnvironment, @@ -350,6 +351,36 @@ describe('worktreeEnvironment (per-worktree ports and databases)', () => { }) }) +describe('s3EnvCopiedLines (OPS52 media storage, all-or-nothing)', () => { + const full = () => ({ + S3_BUCKET: 'teqo-media', + S3_ENDPOINT: 'http://100.119.220.31:3900', + S3_ACCESS_KEY_ID: 'AK-test', + S3_SECRET_ACCESS_KEY: 'secret', + }) + + it('copies all five S3_* lines when the four required keys are present', () => { + expect(s3EnvCopiedLines({ ...full(), S3_REGION: 'garage' })).toEqual([ + 'S3_BUCKET=teqo-media', + 'S3_ENDPOINT=http://100.119.220.31:3900', + 'S3_ACCESS_KEY_ID=AK-test', + 'S3_SECRET_ACCESS_KEY=secret', + 'S3_REGION=garage', + ]) + }) + + it('copies nothing when no S3_* key is set (local storage fallback)', () => { + expect(s3EnvCopiedLines({ DATABASE_URL: 'x' })).toEqual([]) + }) + + it('copies nothing on a partial set — a partial config would abort the boot', () => { + const partial: Record = { ...full() } + delete partial.S3_SECRET_ACCESS_KEY + expect(s3EnvCopiedLines(partial)).toEqual([]) + expect(s3EnvCopiedLines({ ...full(), S3_BUCKET: '' })).toEqual([]) + }) +}) + describe('worktreeEnvFileContents (dev/test env parity)', () => { const env = worktreeEnvironment({ branch: 'C15-fullcalendar', code: 'C15' }) const lines = worktreeEnvFileContents({ @@ -358,7 +389,11 @@ describe('worktreeEnvFileContents (dev/test env parity)', () => { generatedBy: 'gerado por pnpm worktree next', env, payloadSecret: 'secret', - copiedLines: ['BLOB_READ_WRITE_TOKEN=blob', 'VAPID_PRIVATE_KEY=key'], + copiedLines: [ + 'S3_BUCKET=teqo-media', + 'S3_ENDPOINT=http://100.119.220.31:3900', + 'VAPID_PRIVATE_KEY=key', + ], }) const dev = lines.dev.join('\n') const test = lines.test.join('\n') @@ -386,11 +421,12 @@ describe('worktreeEnvFileContents (dev/test env parity)', () => { ) expect(dev).toContain(`PORT=${devPortForSlot(env.slot)}`) expect(dev).toContain('PAYLOAD_SECRET=secret') - expect(dev).toContain('BLOB_READ_WRITE_TOKEN=blob') + expect(dev).toContain('S3_BUCKET=teqo-media') + expect(dev).toContain('S3_ENDPOINT=http://100.119.220.31:3900') expect(dev).toContain('VAPID_PRIVATE_KEY=key') expect(test).not.toContain('PAYLOAD_SECRET') expect(test).not.toContain('PORT=') - expect(test).not.toContain('BLOB_READ_WRITE_TOKEN') + expect(test).not.toContain('S3_BUCKET') expect(test).not.toContain('VAPID_PRIVATE_KEY') })