O escopo A/C do gate (PR #1370) é superado por decisão do dono (2026-10-01, aval jurídico declarado): o índice vira anônimo (archivePhotoFace, foto + descritor 128-d, sem nome/vínculo), o enrollment por pessoa morre (faceSubject/faces:enroll removidos) e qualquer visitante encontra as próprias fotos aprovadas. - faces:index persiste os descritores por foto (transação substitui as linhas + marker = modelo); desaprovar/remover/deletar foto apaga os descritores (hooks + FK cascade) - POST /api/fotos/selfie casa o vetor da consulta contra o índice em memória (TTL 60 s) e devolve só a interseção com o read aprovado do C233, sem score/nome de terceiro; leave-index apaga os descritores que casarem - Consentimentos seguem fail-closed: busca-selfie-fotos (consulta) e busca-selfie-indice (aviso público do índice), textos versionados em scripts/lib/faceConsentTexts.mjs e provisionados por pnpm seed:face-consents - pnpm archive:publish aprova o acervo em lote (draft -> approved; removed nunca) com guardas e recibo - C243: seção condicional SelfieSearchHomeSection (port do design hi-fi, cenas 390/1280) entre a Central e a newsletter; sem engine/captura na home e sem CTA morto quando fechada - docs: planos C242/C243, AGENTS-public, runbook §C242, changelog Provas: gate:fast 457 arquivos/5.055 testes; int full 109/1.075; e2e frontendFotosSelfie 9/9; knip verde.
13 KiB
Public site domain (AGENTS layer — load when touching (frontend))
Photo album (/fotos) — C233
The public album over the private archive (archivePhoto, C231/C232): only publicationStatus: 'approved' photos are read (getApprovedArchivePhotoItems, src/utilities/archivePhotos/archivePhotoReads.ts), cached under the collection listing tag archivePhotos and busted by the collection's afterChange/afterDelete — an approved→removed edit pulls the photo and its media down without a deploy. The route canonicalizes to /fotos; the single-value facets are data (day, YYYY-MM-DD), municipio (catalog slug), atividade (an ARCHIVE_PHOTO_SCENES value), pessoa (slug of a canonical publicFigureCatalog name) plus q/pagina; ?foto=<id> is the non-canonical overlay and /fotos/<id> keeps 404ing (no page per photo). Media goes through the same-origin proxy /fotos/<id>/midia (?tamanho=grade resizes on the fly, ?download=1 names jorge-solla-1313-foto-<id>.<ext>, X-Robots-Tag: noindex); the municipality name/slug are snapshotted on the row (municipalityName/municipalitySlug) because the municipality collection is campaign-only.
The album global photoAlbum holds the two product knobs: published (kill switch — only false closes; an absent row means the schema default true) and removalChannelUrl (the visible "é você nesta foto? peça a remoção" channel, never invented in code). Publishing the album or approving a photo requires a valid channel (fail-closed guard in the global and in the collection); the album has no loading.tsx on purpose — a Suspense boundary would turn the closed album's notFound() into a 200. The "Fotos" discovery link in CampaignFooter appears while there are approved photos (and always on /fotos itself).
Selfie search (/fotos/encontre) — C234/C242
The visitor's photo never leaves their device: the browser runs the face-api nets locally (models served same-origin from /fotos/modelos, copied by scripts/copy-face-vision-assets.mjs in prebuild/predev/Docker) and posts only the 128-float descriptor to POST /api/fotos/selfie ({ vector, intent: 'search' | 'leave-index' }, JSON ≤4 KB, same-origin, rate-limited 20/10 min per hashed IP with its own key namespace). The answer is a list of approved photos ({ ok, found, photos }), never a score, a percentage or a third-party name — the view model (src/lib/faceSearch.ts toFaceSearchPhotoView) has no such fields by shape. Recusals: 400 body, 403 origin, 429 throttle, 404 when photoAlbum.published === false or selfieSearchEnabled !== true, 503 when either Consent is missing. Since C243 the public homepage carries a conditional discovery section (SelfieSearchHomeSection) that only presents and links — no capture/engine there.
The index is scope B (owner decision, 2026-10-01, supersedes the A/C gate of PR #1370): an anonymous descriptor index of the approved archive, so ANY visitor can find themselves. The batch (pnpm faces:index --apply|--verify, maintenance service with the C231/C232 write guards) downloads each approved photo, detects faces with the same engine and persists identity-free rows in archivePhotoFace (photo + 128-float vector + model); archivePhoto.faces.checkedKey stores only the model, so a model change marks everything stale. There is no enrollment and no per-person consent: the Consent rows are the visitor's query consent and the public index notice. The rows die with the public status — the collection hooks purge them when a photo stops being approved or is deleted (plus the FK ON DELETE CASCADE) — and the answer always intersects with the C233 approved read. The CLI engine (scripts/lib/faceEngine.mjs) loads the same nets from node_modules.
Both sides are fail-closed: selfieSearchEnabled defaults to false; the two Consent keys (busca-selfie-fotos query consent, busca-selfie-indice public index notice, in src/lib/campaignConsentKeys.ts) are provisioned by pnpm seed:face-consents from the texts versioned in scripts/lib/faceConsentTexts.mjs — without both admin rows the page renders the closed state and the API refuses. Opt-out is self-service ("Minha presença" → face match deletes the matching descriptors, so the face stops answering); photo takedown keeps using photoAlbum.removalChannelUrl. Opening the surface also requires approving the archive (pnpm archive:publish, bulk draft → approved, never touching removed). E2E builds stub the engine with NEXT_PUBLIC_FACE_SEARCH_STUB=1 (window.__faceSearchStub); the real engine is exercised by the CLI canary and manually. Runbook: docs/ops/teqo-1313-deploy.md §C242.
Posts & Tags (news / publications)
The public news/publications system is backed by two collections (both in the Publicações admin group) plus the helpers in src/utilities/posts.ts.
post(src/collections/Post.ts) — fields:title,slug(unique, indexed, auto-slugified fromtitlewhen left empty),type(select enumnoticia|campanha|artigo|evento),category(required single relationship →tag),tags(hasManyrelationship →tag),subtitle,coverImage(upload →media),publishedDate,body(richText). Has drafts/versions (schedulePublish,maxPerDoc: 5).tag(src/collections/Tag.ts) — fields:name,slug(unique, indexed, auto-slugified fromname), andhidden(checkbox, admin label "Esconder", defaultfalse). Tags are the shared taxonomy for bothpost.categoryandpost.tags.
Tags are a taxonomy, not a person record. The Contact convention applies to people; tag is publication metadata (categories + a visibility control flag), so post/tag relations do not touch Contact. Don't fold taxonomy into Contact or vice-versa.
Electoral visibility control (hidden + fail-closed isPostVisible). Marking a tag hidden hides every post that references it — as category or in tags — with a single toggle. This exists so campaign / pre-candidacy content (tagged eleitoral) can be pulled from the public site during the electoral period. isPostVisible(post) (src/utilities/posts.ts) returns true only when the post is published AND none of its related tags (category + tags) is hidden. It fails closed: an unpopulated relation (a bare numeric id) is treated as hiding, so callers must fetch with depth >= 1. Every public read filters through getVisiblePosts(), which applies this predicate to the cached published list.
URLs and Portuguese SEO values. The canonical article path is /[type]/[category]/[slug] (e.g. /noticia/saude/<slug>), with /[type] and /[type]/[category] listing routes and the home "Últimas notícias" list. The type enum values (noticia|campanha|artigo|evento) and tag/category slugs (saude, eleitoral, …) are deliberately Portuguese for SEO and are data, not identifiers — never translate them (see the naming-conventions bullet above). The route folders are [type]/[category]/[slug] (English param keys); the article route redirects any stale/mismatched URL to the canonical path derived from the post's current type + category slug. Article pages emit JSON-LD (Article) and Open Graph metadata.
Caching (mixed ISR, posts tag). Public reads go through the unstable_cache wrappers in src/utilities/posts.ts (getCachedPublishedPosts, getCachedPostBySlug), all tagged posts. The routes set export const dynamicParams = true and build generateStaticParams from getVisiblePosts(): known paths are statically generated, unknown ones render on demand, and everything stays cached until the posts tag is busted. revalidatePostsListing() (src/utilities/documents.ts) is revalidateTag('posts') (the listing tag is `${collection}s` → posts), so one call busts the home page, every listing route, and every article route at once.
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 ~43 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 <slug>.<ext> 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). pnpm db:seed:posts --dry-run is the plan-only pre-flight (fetches, resolves covers, reports what WOULD be created — no writes). Since OPS60, sync with the S3_* envs set also requires explicit bucket-write intent: SEED_MEDIA_CONFIRM=1 (fail-closed, same isTruthyEnv pattern as MEDIA_RECOVER_CONFIRM; without it the sync refuses with exit 1 before any write — the S3_* envs alone must never arm a bucket write, since a worktree with a prod-credentials copy running sync against a local DB would write covers to the prod bucket). --dry-run stays flag-free. The OPS58 runbook documents the production sync (post-merge, homeserver, stack env, dry-run → sync → bust posts cache → verify) — copy it for future syncs (see docs/plans/ops58-sincronizar-posts-prod-impl.md).
Revalidating after a manual/direct DB change
Admin edits made through the deployed app self-revalidate via the afterChange hooks above. But any write that does not go through the deployed Payload runtime — direct SQL, pnpm db:seed:posts, a DB restore / db:pull — will not bust production's frozen posts cache. (Even when such a write triggers the hook, its revalidateTag('posts') runs in that CLI/local process and cannot touch the deployed server's cache.) The pages keep serving stale content until the tag is busted.
Bust it with the secured endpoint (src/app/(frontend)/api/revalidate/route.ts):
# News (`posts` tag — default)
curl -X POST "https://<prod-domain>/api/revalidate" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
# Privacy policy global after Onda 0 migration/seed (`global_privacy-policy`)
curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
- Auth: send the secret via
x-revalidate-secretorAuthorization: Bearer <secret>; it is compared toREVALIDATE_SECRETwith a constant-time check. REVALIDATE_SECRETmust be set in the production env (and in your local env if you want to test the endpoint). It is documented in.env.example.- Tag: optional
?tag=query or JSON body{ "tag": "..." }(query wins). Allowlist:posts(default),global_privacy-policy,election-tse(bust after re-runningpnpm db:seed:tseso the cached elections-tab reads refresh),municipality-catalog(bust after a migration/seed that adds or renames a município so chip indexes andunstable_cacheloaders refresh),social-feed(bust after a direct-DB write to thesocial-feed-settingsglobal — e.g. an admin-less seed of the YouTube/Instagram feed config; admin edits self-revalidate),archivePhotos(bust after a direct-DB write toarchivePhotorows — admin edits self-revalidate via the collection hook; the public album/fotoscaches under it). Unknown tag →400. - Instagram sync status (S11): the global's
instagramSyncStatusjsonb (hidden) is written by the render path, byafterChange(re-sync only when IG credentials changed), and by the admin buttonPOST /api/social-feed/sync(admin session viapayload.auth+ same-origin guard); theInstagramSyncStatusPanelui field renders it. Runbook de credenciais:docs/ops/instagram-feed-token-runbook.md. - Responses:
200 { revalidated: true, tag: '...' }on success;401if the secret is missing/wrong;500ifREVALIDATE_SECRETis not configured on the server. - This POST is the required last step of the post-seed / direct-DB-change runbook.