feat(C234): busca por selfie no álbum público — índice A/C consentido, selfie on-device

- /fotos/encontre + POST /api/fotos/selfie: só o vetor 128-d, sem imagem, sem score e
  sem nomes de terceiros; fail-closed pela flag photoAlbum.selfieSearchEnabled (default
  false) e pelos Consents por chave estável (busca-selfie-fotos/busca-selfie-indice, não
  seedados — a superfície nasce fechada até o aval jurídico/DPIA).
- Índice A/C do gate (PR #1370): só quem aderiu é indexado; descriptors do acervo são
  transitórios. Collection faceSubject (admin-only, sem vínculo com Contact), vínculos
  matchedPhotos e marker faces.checkedKey no archivePhoto (modelo + enrolledAt).
- Engine @vladmandic/face-api nos dois lados: browser lazy com modelos same-origin
  (copy-face-vision-assets no prebuild/predev/Docker) e CLI node-wasm sem tfjs-node.
  CLIs ops-only faces:enroll / faces:index --apply|--verify com guardas C231/C232.
- Opt-out self-service por reconhecimento (leave-index) + canal de remoção do C233;
  rate limit próprio (namespace selfie:<hash>) e bounded body extraído do beacon.
- UI portada classe-a-classe do design estendido (paridade certificada, tier primário);
  e2e frontendFotosSelfie (manifest+curado+projeto, engine stubada) e canário do engine
  real; runbook §C234, contrato em AGENTS-public.md e changelog.
- Migration aditiva add_face_subject (SQL revisado); hard-stops aprovados na sessão.
This commit is contained in:
Francisco Solla 2026-09-27 19:03:30 -03:00
parent 87002b7e82
commit 81f87c78d1
65 changed files with 27450 additions and 214 deletions

View file

@ -36,6 +36,14 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
# NEXT_PUBLIC_CARDS_CUTOUT_STUB=1 pnpm build
NEXT_PUBLIC_CARDS_CUTOUT_STUB=
# C234 — test seam for the on-device selfie-search engine. `1` swaps the
# face-api engine (and its ~7 MB of same-origin models) for a deterministic
# descriptor derived from the file bytes (e2e builds and the Playwright
# webServer only; the real engine must run in dev/production). Local E2E_PROD=1
# builds set it too:
# NEXT_PUBLIC_FACE_SEARCH_STUB=1 pnpm build
NEXT_PUBLIC_FACE_SEARCH_STUB=
# Shared secret guarding POST /api/revalidate, which busts ISR cache tags on demand.
# Needed after direct-DB writes (e.g. `pnpm db:seed:posts`, Onda 0 migrations) since those
# bypass the deployed Payload hooks. Default tag is `posts`; allowlisted tags also include

View file

@ -224,6 +224,7 @@ jobs:
# S15 — deterministic cutout stub: the e2e build never downloads the
# MediaPipe wasm nor runs inference.
NEXT_PUBLIC_CARDS_CUTOUT_STUB: '1'
NEXT_PUBLIC_FACE_SEARCH_STUB: '1'
- name: E2E tests (blast radius — manifest-selected or curated specs)
if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
# OPS72 + OPS86: the PR never runs e2e full — a high-risk diff runs

View file

@ -198,6 +198,7 @@ jobs:
# S15 — deterministic cutout stub: the e2e build never downloads the
# MediaPipe wasm nor runs inference.
NEXT_PUBLIC_CARDS_CUTOUT_STUB: '1'
NEXT_PUBLIC_FACE_SEARCH_STUB: '1'
- name: E2E tests (full suite, single job, 4 workers)
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
# No `--` separator: pnpm would forward it verbatim to playwright and

2
.gitignore vendored
View file

@ -88,6 +88,7 @@ next-env.d.ts
/data/acervo-index/
# C232 — photo-archive cataloguing receipts (pnpm archive:catalog)
/data/archive/
/data/face/
# Solla × Ceuci report HTML cache (scripts/build-solla-ceuci-salvador-report.mjs)
/data/solla-ceuci-salvador/
@ -154,6 +155,7 @@ next-env.d.ts
# S15 — MediaPipe wasm runtime, copied from node_modules at dev/build time
/public/cards/mediapipe/
/public/fotos/modelos/
/data/encode/
/data/poster-candidates/

View file

@ -6,6 +6,14 @@ The public album over the private archive (`archivePhoto`, C231/C232): **only `p
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
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 the query Consent is missing.
**The index is A/C by gate decision (PR #1370): only people who adhered and consented are searchable; there is no anonymous face index of the archive (that is option B, gated on DPIA + legal review).** Enrollment and indexing are ops-only CLIs (`pnpm faces:enroll`, `pnpm faces:index --apply|--verify`), run from the maintenance service with the same write guards as C231/C232 (`TEQO_ENV`, `FACE_*_CONFIRM=1`, mirrored `S3_*`). The batch computes descriptors transiently and persists only the photo↔subject links (`faceSubject.matchedPhotos`) plus the `archivePhoto.faces.checkedKey` marker (model + each subject's `enrolledAt`, so the batch's own writes never invalidate it). The CLI engine (`scripts/lib/faceEngine.mjs`) loads the same nets from `node_modules`; a model change invalidates the index (re-enroll + `--refresh`).
Both sides are **fail-closed**: `selfieSearchEnabled` defaults to `false`, and the two Consent keys (`busca-selfie-fotos` for the query, `busca-selfie-indice` for enrollment, in `src/lib/campaignConsentKeys.ts`) are never seeded — without the admin row with the exact key the page renders the closed state and the API refuses. Editing the enrollment text makes every subject ineligible until re-consent (`consentHash` snapshot). Opt-out is self-service ("Minha presença" → face match removes the vector and the links, status `removed`); photo takedown keeps using `photoAlbum.removalChannelUrl`. 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` §C234.
## 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`.

View file

@ -47,6 +47,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
# same-origin (the files are gitignored build artifacts, never committed).
RUN node scripts/copy-card-vision-assets.mjs
# C234 — copy the face-api model weights into public/ so the selfie search runs
# same-origin too (same gitignored-artifact contract).
RUN node scripts/copy-face-vision-assets.mjs
# OPS99: Generate importMap with dummy S3_* envs BEFORE next build.
# This ensures the production image always has the S3ClientUploadHandler entry,
# eliminating the class of bugs where the admin goes blank (OPS69/OPS72/OPS73).

View file

@ -0,0 +1 @@
**C234 — Busca por selfie — visitante encontra as fotos em que aparece (2026-09-27):** o álbum público do C233 ganha a busca por selfie **fechada por padrão**: `/fotos/encontre` (noindex, 404 com a flag `photoAlbum.selfieSearchEnabled` desligada — default `false` — ou o álbum fechado; estado fail-closed da cena 7 sem o Consent) e `POST /api/fotos/selfie`, que aceita **só o vetor 128-d** (JSON ≤4 KB, same-origin, rate limit 20/10 min por IP hasheado com chave própria `selfie:<hash>` sobre o dono do beacon; sem imagem, sem score, sem nomes de terceiros). A selfie nunca sai do aparelho: o engine `@vladmandic/face-api` (mesmos três nets do CLI, `tinyFaceDetector`+`faceLandmark68Net`+`faceRecognitionNet`, opções compartilhadas em `src/lib/faceSearch.ts`) roda no browser com os modelos same-origin (`/fotos/modelos`, artefato de build copiado por `scripts/copy-face-vision-assets.mjs` no prebuild/predev/Docker) e no lote pela build node-wasm (sem `tfjs-node`, sem browser headless). **Escopo A/C do gate (PR #1370): só quem aderiu é indexado; índice anônimo do acervo não existe.** A collection nova `faceSubject` (admin-only, sem vínculo com `Contact`) guarda o descriptor consentido, o snapshot do `consentHash` de `busca-selfie-indice` e os vínculos derivados `matchedPhotos`; o enrollment e a indexação são ops-only (`pnpm faces:enroll`, `pnpm faces:index --apply|--verify`, guardas C231/C232, recibos sem vetor/bytes em `data/face/reports/`), com descriptors do acervo transitórios (nunca persistidos) e o marker `archivePhoto.faces.checkedKey` (modelo + `enrolledAt` de cada sujeito elegível — os writes do próprio lote não invalidam a revisão). A consulta resolve os dois Consents por chave estável (`busca-selfie-fotos`/`busca-selfie-indice`, **não seedados**: a superfície nasce fechada até o aval jurídico/DPIA), exige `status: active` + modelo corrente + hash vigente (texto editado ⇒ inelegível até re-consentimento) e cruza o match com o read aprovado do C233 (draft/removida nunca aparecem; a foto removida some sem deploy). Opt-out self-service por reconhecimento ("Minha presença" → `leave-index` apaga vetor e vínculos, status `removed`; sem match, encaminha ao canal de remoção do álbum). Migration aditiva `add_face_subject` (tabela + join + colunas do marker + flag do global; SQL revisado; nada do acervo muda). UI portada classe-a-classe do design estendido (entrada entre hero e facetas com o submit do álbum secundário, consentimento com o richText do Consent, escolha com prévia local, processando, resultado em grade larga sem "Quem aparece", vazio honesto A/C, presença + diálogo destrutivo com trap de Tab, e os estados de navegador incompatível/erro/limite/rede) e **`Design tier: openai/gpt-5.6-sol` com paridade certificada** contra o app renderizado (3 rodadas, 390/1280). Testes: unit do contrato puro/limiar/schema/parsers/guardas, int do enrollment+índice e do endpoint sobre `teqo_test` (só aprovadas, sem nomes/score, opt-out, rate limit, same-origin), e2e novo `frontendFotosSelfie` (manifest + curado + projeto, engine stubada) e canário manual do engine real (enroll→index→vínculo). Runbook §C234 e contrato em `AGENTS-public.md`. Impl plan: `docs/plans/busca-fotos-por-selfie-impl.md`.

View file

@ -1429,6 +1429,65 @@ Rollback: a migration `add_archive_photo_catalog` é aditiva; o rollback funcion
é limpar o grupo `catalog`/`curatedFields` no admin. Reexecutar nunca duplica nem
sobrescreve curadoria.
## C234 — busca por selfie no álbum público
A busca por selfie (`/fotos/encontre` + `POST /api/fotos/selfie`) nasce
**fechada**: a flag `photoAlbum.selfieSearchEnabled` é `false` por default e o
fluxo só abre com o **Consent de consulta** (`busca-selfie-fotos`) configurado
no admin — sem ele a página mostra o estado fail-closed e a API recusa (503).
O índice é A/C (decisão do gate, PR #1370): só rostos de pessoas que aderiram
entram; não existe índice anônimo do acervo. A selfie nunca sai do aparelho do
visitante (o navegador envia só o vetor 128-d).
### Abertura (depois do aval jurídico/DPIA registrado)
1. No admin (`/admin`), crie os dois Consentimentos com as chaves exatas:
`busca-selfie-fotos` (consulta, texto mostrado no fluxo) e
`busca-selfie-indice` (adesão, assinado com a pessoa no enrollment). Sem os
dois, o fluxo não abre / ninguém é elegível.
2. Inscreva cada pessoa que aderiu (selfie com **um** rosto, com a pessoa
presente e o consentimento assinado). Rode no homeserver, no serviço de
manutenção do alvo (o engine roda local, sem endpoint externo):
```bash
cd ~/stack
# plano (dry-run: detecta o rosto e resolve o consentimento; não grava)
docker compose --profile maintenance run --rm -v /srv/face-selfies:/app/data/face-selfies:ro -v /srv/face-reports:/app/data/face/reports teqo-1313-migrate pnpm faces:enroll --label "<nome interno>" --selfie /app/data/face-selfies/<arquivo>.jpg
# grava (exige TEQO_ENV=production e FACE_ENROLL_CONFIRM=1):
docker compose --profile maintenance run --rm -e TEQO_ENV=production -e FACE_ENROLL_CONFIRM=1 -v /srv/face-selfies:/app/data/face-selfies:ro -v /srv/face-reports:/app/data/face-reports teqo-1313-migrate pnpm faces:enroll --label "<nome interno>" --selfie /app/data/face-selfies/<arquivo>.jpg --apply
```
3. Indexe o acervo aprovado para os inscritos (canário primeiro):
```bash
docker compose --profile maintenance run --rm -e TEQO_ENV=production -e FACE_INDEX_CONFIRM=1 -v /srv/face-reports:/app/data/face/reports teqo-1313-migrate pnpm faces:index --apply --limit 20
# sem --limit processa todas as aprovadas; adicionar/reenrolar alguém
# muda a revisão e a próxima execução reprocessa tudo (~6,5k fotos).
docker compose --profile maintenance run --rm teqo-1313-migrate pnpm faces:index --verify # inventário read-only; sai 1 com pendências
```
4. Só então ligue `Busca por selfie` no global **Álbum de fotos** (admin). O
kill switch é imediato (sem deploy); desligar fecha a página (404) e a API.
### Remoção e re-consentimento
- **Sair do índice**: a própria pessoa usa "Minha presença" no fluxo (selfie
confere o vínculo; o vetor e os links são apagados e o status vira
`removed`). O pedido de **remoção de foto** continua no canal do álbum.
- **Texto do Consent editado**: os inscritos ficam inelegíveis até
re-consentimento (o `consentHash` do enrollment é comparado com o texto
vigente) — re-rode `faces:enroll --subject <id>` com a pessoa.
- **Troca de modelo do engine**: `FACE_SEARCH_MODEL` muda ⇒ o índice inteiro
fica stale; re-inscreva (`--subject`) e rode `faces:index --refresh`.
- **Rollback**: desligue a flag; a migration `add_face_subject` é aditiva
(tabela `face_subject`, colunas `faces_*` em `archive_photo` e
`selfie_search_enabled` no global). Nada do acervo público muda.
### Guardas e recibos
`--apply` exige `TEQO_ENV` casando o banco exato, `FACE_ENROLL_CONFIRM=1` /
`FACE_INDEX_CONFIRM=1` fora do dev local e as quatro `S3_*` (mídia espelhada);
`ALLOW_REMOTE_DB` é recusado. Recibos JSON em `data/face/reports/` (nunca
levam vetor nem bytes de imagem). O engine carrega os mesmos modelos do
navegador de `node_modules`; a cópia same-origin para o browser é artefato de
build (`scripts/copy-face-vision-assets.mjs`).
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)

View file

@ -0,0 +1,250 @@
# Impl: C234 — Busca por selfie — visitante encontra as fotos em que aparece
Status: aprovado (modo `--auto`) — hard-stops pendentes de aprovação humana explícita na sessão (migração de schema e contrato público)
Atualizado em: 2026-09-27
Issue: #1369
Intenção: docs/plans/busca-fotos-por-selfie.md
Design UI (gate): docs/plans/busca-fotos-por-selfie-ui-design.html
Appetite restante: herdado (~2–3 dias eng + ops de enrollment/index + aval jurídico de produto) — **uma superfície nova de biometria sobre o acervo aprovado do C232/C233** (`/fotos/encontre` + `POST /api/fotos/selfie`), sem segundo cadastro de pessoa. Cortes explícitos para caber: enrollment e indexação são **ops-only (CLI)**; sem UI de enrollment; sem captcha; sem modo B (rostos anônimos do acervo); sem retenção de selfie; sem score.
> Aprovado pelo próprio agente via `--auto` (modo autônomo do `work-issue`); os hard-stops continuam exigindo aprovação humana explícita e estão **pendentes** (migração de schema, contrato público, Consent/DPIA, dependência de engine e enrollment ops-only). **Decisão do gate (PR #1370): "Escopo do índice facial A/C no MVP (B só com DPIA + aval jurídico)".** Interpretação canônica a implementar: **só entram no índice rostos de pessoas que aderiram/consentiram**; a busca por selfie só devolve se o rosto consultante já estiver indexado; **índices anônimos de rostos do acervo são proibidos** (isso é B). Timing: superfície pública de biometria **não abre** sem revisão jurídica — o código nasce **fechado** (flag própria default `false` + Consent fail-closed); a pessoa geral (não indexada) recebe **vazio honesto**.
## Leitura da intenção
- **Outcome:** com consentimento ativo e adesão ao índice, o visitante tira/envia uma selfie, o reconhecimento roda no dispositivo (a imagem nunca sai dele) e ele recebe somente as fotos **públicas aprovadas** em que aparece — sem nomes de terceiros e sem score; sem consentimento configurado o fluxo recusa com linguagem clara (nem na UI nem no servidor); a remoção (sair do índice / despublicar foto) funciona e fica registrada.
- **O que NÃO negociar:**
- **Escopo A/C travado pelo gate:** índice só de rostos consentidos; busca que só devolve para rosto já indexado; proibido indexar rostos anônimos do acervo (B) sem DPIA + aval jurídico.
- **Fail-closed de Consent/LGPD:** sem Consent resolvido por `key` estável, o fluxo não habilita — nem na UI, nem no servidor (conceito travado do repo).
- **A selfie nunca sai do dispositivo:** ao servidor vai só o vetor (128 floats), nunca imagem; sem upload, sem galeria, sem retenção.
- **Resposta mínima:** só as fotos aprovadas da pessoa consultante; nunca “outras pessoas parecidas”, nunca nome de terceiro, nunca score/percentual em nenhum estado.
- **Nada de foto privada/bruta:** o resultado é o mesmo acervo aprovado do C232/C233 (`publicationStatus: 'approved'`, `archivePhotoIsPublic` — `src/lib/archivePhotoCatalog.ts:128-129`).
- **Sem cadastro paralelo:** `faceSubject` não é pessoa; sem vínculo com `Contact`/leadership; mínimo PII (label interno + consent + vetor).
- **Antiabuso anônimo** (rate limit, same-origin, body cap), sem captcha no MVP.
- **O que reavaliar (hipóteses da “Direção no codebase”):**
- A intenção cita `src/utilities/ai/rateLimit.ts` como antiabuso; o dono real do throttle anônimo por IP é `src/utilities/content/contentEventRateLimit.ts` (D8) — `ai/rateLimit.ts` é por usuário de chat, não serve.
- A resposta do C233 inclui `people`/`peopleLabel` (`src/lib/archivePhotoPublicCatalog.ts:208-230,285`) — o view model do C234 **precisa** cortar esses campos (D5/D9).
- O CTA “Encontre você nas fotos” foi deliberadamente removido do artefato do C233 (`src/components/fotos/ArchivePhotoHero.tsx:3-6`) — o C234 traz a entrada própria (D9).
- “Upload consentido com retenção zero” da intenção: **rejeitado** — o caminho é vetor-only (sem upload), o que já satisfaz o aceite por construção.
## Abordagem recomendada
```mermaid
flowchart LR
subgraph OPS["Ops — assessoria · CLI na workstation"]
ENR["pnpm faces:enroll\nselfie + consent"] --> SUB[("faceSubject\nlabel · vector 128d\nconsentHash · status")]
IDX["pnpm faces:index --apply"] --> DLD["downloadPrivateMediaToFile\nsharp ≤1024px"]
DLD --> NODE["engine node-wasm\n@vladmandic/face-api"]
NODE --> CMP["distância euclidiana\nthreshold interno 0.45"]
SUB --> CMP
CMP --> LNK["matchedPhotos\njoin faceSubject↔archivePhoto\ntransação + fresh-read"]
end
subgraph PUB["Público"]
ALB["/fotos · C233"] -- "CTA cena 1" --> ENC["/fotos/encontre\nRSC: publicado? + flag + Consent\nfail-closed · noindex"]
ENC --> BRW["engine on-device (lazy)\nselfie NUNCA sai do aparelho"]
BRW -- "só o vetor 128d · JSON ≤4KB" --> API["POST /api/fotos/selfie\nsame-origin · rate limit\nintent search|leave-index"]
API --> ELG["faceSubject ativos\nconsentHash vigente"]
ELG --> APR["getApprovedArchivePhotoItems\n∩ matchedPhotos"]
APR -- "view sem nomes de terceiros\nsem score · limite 60" --> ENC
API -- "leave-index" --> OUT["status removed\nvector null · removedAt\nmatchedPhotos limpo"]
end
```
**Opções consideradas (abordagem geral):** A) engine único `@vladmandic/face-api` nos dois lados (browser on-device + CLI node-wasm), subjects consentidos em collection nova, vínculos fotos↔subject calculados em lote CLI e consultados por um route handler novo; B) engine/server-side de terceiro com upload da selfie; C) serviço separado (Python/Rust) ou `MediaPipe` no browser.
**Recomendação:** **A** — mantém a selfie no aparelho (o aceite de produto), usa a mesma família de modelo e a mesma matemática no browser e no CLI, reusa os donos existentes (approval gate do C233, `downloadPrivateMediaToFile`, `withPayloadTransaction`, `getConsentByKey`, guardas de CLI do C231/C232) e cabe no appetite sem infra nova.
**Rejeitadas:** **B** porque manda biometria de cidadão a terceiro e a selfie sairia do dispositivo (anti-goal explícito); **C** porque cria runtime/serviço novo fora do appetite e sem precedente no repo.
### Componentes / mudanças
**Novos — schema/dados (na MESMA migration aditiva `add_face_subject`)**
- **`src/collections/FaceSubject.ts`** (novo): slug `faceSubject`, labels “Pessoa no índice facial”/“Pessoas no índice facial”, grupo `Comunicação` (mesmo dono do acervo, `ArchivePhoto.ts:286-288`), sem versões; access `payloadAdminOnly` nas quatro operações (precedente `ArchivePhoto.ts:300-305`); campos: `label` (text, required; nome de uso interno — nunca público), `consent` (relationship → `consent`, required), `consentHash` (text, readOnly; snapshot de `hashConsentContent` via `getConsentByKey`, `src/utilities/campaignConsent.ts:72-100`), `vector` (json, `hidden` + readOnly; 128 floats — nunca sai do servidor), `model` (text, readOnly; ex. `face-api@1.7.15/faceRecognitionNet`), `status` (select `active|removed`, default `active`, index), `removedAt` (date, readOnly), `matchedPhotos` (relationship hasMany → `archivePhoto`, readOnly; join table). Sem hooks.
- **`src/collections/ArchivePhoto.ts`** (editar `:311-505`): grupo `faces` (`hidden: true`) com `checkedAt` (date) e `checkedKey` (text), readOnly — o marker de incremental do lote (D4). `defaultColumns`/access/hooks intocados.
- **`src/globals/PhotoAlbum.ts`** (editar `:61-80`): campo `selfieSearchEnabled` (checkbox, **default `false`**) — kill switch independente da busca por selfie (o Consent referenciado não pode ser apagado enquanto houver subject, `Consent.ts:8-52` estendido abaixo; sem a flag o “desligar” não teria dono).
- **`src/collections/Consent.ts`** (editar `:24-90`): somar `faceSubject` às contagens do `beforeDelete` (o Consent da adesão não pode ser apagado por baixo dos subjects).
- **Migration `add_face_subject`** (`src/migrations/<stamp>_add_face_subject.ts|.json` + registro no fim de `src/migrations/index.ts`): tabela `face_subject` + join `face_subject_rels`, colunas `faces_checked_at`/`faces_checked_key` em `archive_photo` e `selfie_search_enabled` no global `photo_album`; SQL revisado à mão; nenhuma migration existente tocada; `pnpm migrate:create add_face_subject` → `pnpm migrate` só no banco do worktree → `pnpm generate:types`.
**Novos — contrato puro (client-safe, sem I/O)**
- **`src/lib/faceSearch.ts`** (novo): `FACE_SEARCH_DESCRIPTOR_LENGTH = 128`, `FACE_SEARCH_MODEL = 'face-api@1.7.15/faceRecognitionNet'`, `FACE_SEARCH_MAX_DISTANCE = 0.45` (interno, nunca devolvido), `FACE_SEARCH_RESULT_LIMIT = 60`, `FACE_SEARCH_INTENTS = ['search','leave-index']`, `faceEuclideanDistance(a,b)`, `findFaceMatch({ vector, subjects })` (melhor subject sob o threshold), `faceSubjectIsEligible(subject, { model, consentHash })` (ativo + modelo corrente + hash vigente), `toFaceSearchPhotoView(item)` (corta `people`/`peopleLabel`), `faceStubDescriptorFromBytes(bytes)` (seam determinístico do e2e).
- **`src/lib/schemas/faceSearch.ts`** (novo): `z.strictObject` do body `{ vector: number[128] finitos, intent: enum }` — fail-closed (mold `src/lib/schemas/contentEvent.ts:37-59`).
- **`src/lib/campaignConsentKeys.ts`** (editar `:1-40`): `FACE_SEARCH_CONSENT_KEY = 'busca-selfie-fotos'`, `FACE_INDEX_CONSENT_KEY = 'busca-selfie-indice'` + mensagens de ausência (padrão `:16-19`); comentário de cabeçalho ajustado (o arquivo já hospeda a key pública do WhatsApp, `:26`).
**Novos — server-side Payload**
- **`src/utilities/faceSubjects/faceSubjectReads.ts`** (novo, `server-only`): `listSearchableFaceSubjects(payload)` (query `status=active`, `select` explícito de `vector`/`consentHash`/`model`/`matchedPhotos`, `depth 0`, bypass anônimo documentado como `archivePhotoReads.ts:13-24`) e `listFaceSubjects` (para `--verify`). **Sem `unstable_cache`**: a tabela é pequena e o opt-out precisa valer imediatamente (D5).
- **`src/utilities/faceSubjects/faceSubjectEnrollment.ts`** (novo, `server-only`): `enrollFaceSubject({ payload, label, descriptor, consent, subjectId? })` — grava/atualiza com `overrideAccess: true` (CLI sem sessão) e `context: { faceIndex: true }` (precedente `src/utilities/flickr/archivePhotoCatalog.ts:258-267`).
- **`src/utilities/faceSubjects/faceSubjectPhotoIndex.ts`** (novo, `server-only`): `listArchivePhotoFaceIndexQueue({ payload, refresh, subjectId })` (aprovadas + marker) e `indexArchivePhotoFaces({ payload, item, analyze })` — baixa bytes com `downloadPrivateMediaToFile` + `resolvePrivateMediaStaticDir` (`src/utilities/privateMedia/privateMediaResponse.ts:314-344,45`), `sharp` ≤1024px, descriptors **transitórios**, e dentro de `withPayloadTransaction` (`src/utilities/payloadTransaction.ts:86-113`) relê os subjects e **substitui** os vínculos `matchedPhotos` (adiciona os que casaram, remove o vínculo que deixou de casar); grava `faces.checkedAt`/`checkedKey`. Analyzer injetado (molde C232).
- **`src/utilities/content/contentEventRateLimit.ts`** (editar `:17-25,53-73`): budget parametrizável `checkContentEventRateLimit(key, budget)` com o default atual intocado (beacon C213) e budget estrito para a busca (D8).
- **`src/utilities/boundedRequestBody.ts`** (novo): extrai `readBoundedBody` de `src/app/(frontend)/api/content-events/route.ts:47-78` (cap de bytes em streaming); o route de content-events passa a importar (comportamento idêntico) e o route novo reusa — sem segundo twin.
**Novos — rotas públicas**
- **`src/app/(frontend)/fotos/encontre/page.tsx`** (novo): RSC — `photoAlbum.published === false` → 404; `selfieSearchEnabled !== true` → 404; Consent ausente → 200 com o estado fechado da cena 7; resolve o texto do Consent (renderizado por `ConsentText`, precedente `src/app/(campaign)/campanha/convite/[token]/page.tsx:49-63`) e o `removalChannelUrl`; `robots: { index:false, follow:false }`; **sem `loading.tsx`** (mesma razão do C233: `page.tsx:55-59`).
- **`src/app/(frontend)/api/fotos/selfie/route.ts`** (novo): `export const dynamic = 'force-dynamic'`; mesmo-origem (`isSameOriginRequest`, `src/utilities/sameOriginRequest.ts:33-64`); body ≤4KB pelo helper extraído; parse zod; rate limit por IP hasheado; re-checa flag + Consent no servidor; `search` → subjects elegíveis → melhor match sob o threshold → `getApprovedArchivePhotoItems()` (`archivePhotoReads.ts:58-61`) filtrado por `matchedPhotos` → view model sem nomes/score, limite 60; `leave-index` → transação (`status:'removed'`, `vector:null`, `removedAt`, `matchedPhotos:[]`). Envelopes: `{ ok:true, found:true, photos }` | `{ ok:true, found:false }` | `{ ok:true, removed:true|false }` | erros `{ ok:false, error }` (400 body, 403 origem, 429 rate limit, 404 fechado por flag/publicação, 503 Consent ausente). **Nenhuma imagem em nenhum request; nada de score; nada de nomes de terceiros.**
- **`src/app/(frontend)/fotos/page.tsx`** (editar `:119-157`): renderiza `<SelfieSearchEntry>` (link para `/fotos/encontre`) só quando `selfieSearchEnabled === true`; posição validada pelo `designer` (D9).
**Novos — UI client**
- **`src/components/fotos/faceSearchEngine.ts`** (novo): `computeFaceDescriptor(file)` — probes de WebGL/WASM (`cardCutout.ts:97-127`), `import('@vladmandic/face-api')` **lazy** (só quando o fluxo começa), modelos same-origin em `/fotos/modelos/`, cap ≤1024 (face maior quando há mais de um rosto); stub `NEXT_PUBLIC_FACE_SEARCH_STUB=1` no molde `cardCutout.ts:347-394` com `window.__faceSearchStub`.
- **`src/components/fotos/SelfieSearchFlow.tsx`** (novo, client): máquina de estados (consent → pick → processing → results → empty → presence/confirm → removed/no-removal; + engine/unsupported, rate-limited, network); recebe o texto do Consent já renderizado como `children` (regra de client boundary, `engineering-standards.mdc:22-25`); preview com `URL.revokeObjectURL`; a selfie nunca vai a `fetch`.
- **`src/components/fotos/SelfieSearchEntry.tsx`** e **`SelfieSearchResultCard.tsx`** (novos): entrada na `/fotos` e card do resultado **sem** “Quem aparece”, reusando `ARCHIVE_PHOTO_CARD`/botões de `archivePhotoClasses.ts`; o clique do card leva ao detalhe do C233 (`/fotos?foto=<id>`), dono único do detalhe.
**Novos — scripts/CLI**
- **`scripts/copy-face-vision-assets.mjs`** (novo, molde `scripts/copy-card-vision-assets.mjs:24-47`): copia `tiny_face_detector` (~190KB) + `face_landmark_68` (~357KB) + `face_recognition` (~6,4MB) de `node_modules/@vladmandic/face-api/model` para `public/fotos/modelos/` (gitignored); idempotente por tamanho; chamado no `prebuild` (`package.json:24`), no `predev` (`:62`) e no `Dockerfile:48`.
- **`scripts/lib/faceEnrollPlan.mjs`** + **`scripts/lib/faceIndexPlan.mjs`** (novos): parsers de argv e recibos (mold `scripts/lib/archiveCatalogPlan.mjs:1-120`); pinados em `SCRIPTS_SPEC_PINNED` (`scripts/lib/test-affected-core.mjs:31-111`).
- **`scripts/enroll-face-subject.mjs`** (`pnpm faces:enroll`) e **`scripts/index-archive-faces.mjs`** (`pnpm faces:index` — sem `--subject`: o canário é `--limit`; adicionar sujeito invalida a revisão de qualquer forma) (novos): guardas de `scripts/lib/cli.mjs` (`assertWriteConfirm` `:312-320`, `assertEnvironmentDatabaseTarget` `:238-301`, `mirroredMediaRequired` `:197-202`), recibo JSON datado em `data/face/`, falha isolada e estágio nomeado (molde `scripts/catalog-archive-photos.mjs:129-280`). `package.json` ganha os dois scripts junto de `:37-38`.
**Testes/manifest/migration/ops**
- Testes: ver D10. Pins: entry nova em `scripts/lib/e2e-affected-manifest.mjs` (`:204-240` e `:448-460,493-552` conforme D10), curado `:24-58`, pin `tests/unit/e2eAffectedManifest.unit.spec.ts:54-89`, projeto em `playwright.config.ts:211-220` + env do stub (`:260-300`) e workflow (`ci-pr.yml:226`, `deploy.yml:200`).
- Docs: `AGENTS-public.md:3-7` (contrato `/fotos/encontre` + API + flag), runbook C234 em `docs/ops/teqo-1313-deploy.md`, `.env.example` (`NEXT_PUBLIC_FACE_SEARCH_STUB=` junto de `:36-37`), `.gitignore` (`/public/fotos/modelos/`, `/data/face/`), `docs/changelog/2026-09-27-c234.md`.
### Dados → forma (se aplicável)
N/A — a intenção é explícita: **não há dado numérico na superfície**. A única saída é a lista de fotos; score/percentual é anti-goal (o view model não tem campo de similaridade — o shape torna a violação impossível, não apenas proibida).
## Decisões de engenharia
### D1 — Engine facial (browser + CLI)
**Opções:** A) `@vladmandic/face-api` 1.7.15 (MIT) nos dois lados — browser `dist/face-api.esm.js` (TFJS pré-bundlado) e CLI `dist/face-api.node-wasm.js` com `@tensorflow/tfjs` + `@tensorflow/tfjs-backend-wasm`; B) MediaPipe `tasks-vision`; C) `@tensorflow/tfjs-node`; D) Playwright headless; E) API de terceiro.
**Recomendação:** **A** — um único engine e **a mesma matemática** nos dois lados (descriptor 128-d, distância euclidiana, threshold estrito 0.45 interno, nunca devolvido); no browser o import é lazy e os modelos (`tiny_face_detector` + `face_landmark_68` + `face_recognition`, ~7MB) são copiados same-origin para `public/fotos/modelos/` (zero CDN, precedente `copy-card-vision-assets.mjs`); no CLI não há binário nativo (backend wasm do próprio pacote) nem browser — decode com `sharp` (já dependência) → `tf.tensor3d`. O modelo é versionado no registro (`model` no subject; string `face-api@1.7.15/faceRecognitionNet`) e trocá-lo **invalida o índice** (re-enrollment, D4/riscos).
**Rejeitadas:** **B** porque não tem embedder facial — a geometria de landmarks é fraca demais para similaridade de identidade; **C** porque o binário nativo no Node 24 é risco operacional (build/AJV) por zero ganho; **D** porque a inferência no lote via browser headless é infra frágil (e o C232 já rejeitou esse caminho); **E** porque biometria facial de cidadão não vai a terceiro (guardrail de produto).
### D2 — Dados: `faceSubject`, marker de incremental e kill switch
**Opções:** A) collection nova `faceSubject` + grupo hidden `faces` em `archivePhoto` + flag `selfieSearchEnabled` no global `photoAlbum`, tudo na mesma migration aditiva; B) guardar o descriptor em campos `json` do próprio `archivePhoto`; C) reusar `Contact`/`leadership` ou tabela fora do Payload.
**Recomendação:** **A** — cada subject é um registro auditável (label, consent vinculado, snapshot do hash do texto, modelo, status, `removedAt`, vínculos), com PII mínima e sem vínculo de pessoa; `matchedPhotos` e `vector` são `readOnly` no admin (só o CLI escreve, com bypass documentado + `context`); `status` indexado distingue ativo de removido sem apagar histórico; o marker `checkedAt` + `checkedKey` (sha256 de `model` + `id:updatedAt` dos subjects elegíveis ordenados) é o **único jeito honesto** de pular fotos sem reprocessar bytes a cada run (sem a key, um subject novo/atualizado geraria skip errado); a flag `selfieSearchEnabled` default `false` é o kill switch da feature — o Consent referenciado **não** pode ser deletado (`Consent.ts:8-52` estendido), então a flag é o “puxar a tomada” sem deploy.
**Rejeitadas:** **B** porque um vetor por foto não é o modelo de subjects consentidos (sem status/consent/remoção) e mistura o índice ao acervo; **C** porque `Contact` é cadastro de pessoa (invariante do repo) e uma tabela fora do Payload perde migrations/access/admin.
### D3 — Enrollment (ops-only, CLI)
**Opções:** A) CLI `pnpm faces:enroll --label "<nome>" --selfie <arquivo> --consent <key>` (dry-run default; `--apply`; `--subject <id>` para re-enrollment); B) auto-enrollment público a partir da selfie de consulta; C) tela admin de enrollment já no MVP.
**Recomendação:** **A** — a adesão é ato da assessoria **com** a pessoa (consentimento assinado, `Consent` próprio de enrollment com key estável `busca-selfie-indice`, hash snapshot no registro); o CLI exige exatamente **1 rosto** na selfie (0 ou >1 recusam, fail-closed), valida a key do consent contra a constante e grava `status active`; guardas `FACE_ENROLL_CONFIRM=1` + `assertEnvironmentDatabaseTarget` + recibo JSON sem vetor nem bytes; `--dry-run` mostra a contagem de rostos e o que seria gravado. Re-enrollment (`--subject`) é o caminho previsto para troca de modelo/atualização (a assessoria mantém as selfies fora do site — nada de imagem é guardado pelo Teqo).
**Rejeitadas:** **B** porque sem verificação de identidade vira consentimento de terceiro + DoS de opt-out (qualquer um pode remover o rosto alheio do índice) — registrar por quê; **C** adiada com gatilho (assessoria precisar operar sem CLI).
### D4 — Indexação em lote
**Opções:** A) CLI `pnpm faces:index [--apply|--verify] [--limit] [--refresh]` rodando na workstation, vínculos transacionais, descriptors transitórios e marker incremental; B) job in-app disparado por `after()`/fila; C) worker/browser headless.
**Recomendação:** **A** — só fotos `approved`; por foto, baixa os bytes privados (`downloadPrivateMediaToFile`), redimensiona ≤1024px com `sharp`, detecta **todos** os rostos, calcula descriptors **que nunca são persistidos** (se um subject ativo tiver distância < threshold em qualquer rosto, o vínculo nasce) e, dentro de `withPayloadTransaction` com fresh-read dos subjects, substitui `matchedPhotos` (adiciona novos vínculos e **remove o vínculo que deixou de casar**), gravando o marker; `--refresh` ignora o marker; `--limit` canário; `--verify` é read-only e reporta fotos indexadas/stale/pendentes, subjects ativos/removidos/consentimento vencido e os vínculos por subject (breakdown); recibo JSON honesto em `data/face/`; idempotente e retomável; **documentado que adicionar subject novo reprocessa todas as aprovadas** (a `checkedKey` muda). Guardas: `FACE_INDEX_CONFIRM=1`, alvo de banco declarado, mídia espelhada (S3) — mesmo combo do C232.
**Rejeitadas:** **B** porque o repo não tem fila e o lote pertence ao container de manutenção (precedente C231/C232); **C** porque browser headless para 6,5k é frágil; e **persistir descriptors anônimos** é proibido (seria B disfarçado).
### D5 — Consulta: rota, API e contrato de resposta
**Opções:** A) `/fotos/encontre` (RSC fail-closed) + `POST /api/fotos/selfie` com `{ vector, intent }`, matching server-side e view model cortado; B) client falando direto com a REST do Payload; C) upload da selfie e matching server-side.
**Recomendação:** **A** — a página checa `published` (404) e `selfieSearchEnabled` (404) e renderiza a cena 7 (200) sem Consent; o API re-checa tudo no servidor (fail-closed em profundidade), aceita **só o vetor** (JSON ≤4KB, sem multipart/imagem), exige same-origin, aplica rate limit, resolve o Consent de consulta e só então: `search` → subjects elegíveis (`status active` + modelo corrente + `consentHash` vigente) → melhor distância sob **0.45** → fotos **aprovadas** ∩ `matchedPhotos` → view sem `people`/`peopleLabel`/score, no máximo `FACE_SEARCH_RESULT_LIMIT = 60` (mais recentes primeiro; sem paginação no MVP); sem match **ou** sem fotos aprovadas → `{ ok:true, found:false }` (a pessoa não indexada recebe vazio honesto, nunca “parecidos”); `leave-index` → match → transação zera `vector`, marca `removed`, `removedAt` e limpa `matchedPhotos`; sem match → `{ ok:true, removed:false }` e a UI aponta para o canal de remoção do C233 (nunca inventado — `PhotoAlbum.ts:22-37,72-79`). A entrada fica **só na `/fotos`** (cena 1), sem link no header/rodapé no MVP (não anuncia fluxo fechado; posição validada pelo `designer` — D9).
**Rejeitadas:** **B** impossível/sujo (a collection é admin-only e o client enxergaria índice/metadados); **C** contraria o anti-goal “a selfie não sai do dispositivo” — é o inverso do desenho.
### D6 — Consent: duas chaves estáveis, texto nunca seedado
**Opções:** A) adicionar as keys em `src/lib/campaignConsentKeys.ts` (owner atual, client-safe); B) renomear o registry para módulo neutro e reexportar; C) keys hardcoded no CLI/rota.
**Recomendação:** **A** — `busca-selfie-fotos` (consulta; resolve no RSC e na API) e `busca-selfie-indice` (adesão; resolve no `faces:enroll` e vira snapshot em `consentHash`); o arquivo já é o dono dos stable keys e já hospeda key pública (`WHATSAPP_SUBSCRIPTION_CONSENT_KEY`, `src/lib/campaignConsentKeys.ts:26`), então só o comentário de cabeçalho muda; a resolução reusa `getConsentByKey`/`requireConsentByKey` (`src/utilities/campaignConsent.ts:72-111`, bypass de leitura documentado) e o texto é renderizado por `ConsentText`; **nenhum texto é seedado por migration** (nasce fechado; assessoria/jurídico criam o Consent no admin quando liberarem), e o `consentHash` vencido deixa o subject **inelegível** (fail-closed) até re-consentimento. O runbook de abertura (criar o Consent com key+texto → `faces:enroll` → `faces:index` → ligar `selfieSearchEnabled` → verificar) fecha a entrega.
**Rejeitadas:** **B** porque o rename move ~15 import sites sem nenhum ganho de comportamento (gatilho: o nome passar a mentir, ex. 3º fluxo público); **C** porque ID/key hardcoded é proibido pelo invariante.
### D7 — Remoção e atendimento
**Opções:** A) sair do índice self-service por match facial + canal de foto do C233; B) collection de pedidos de remoção com formulário; C) e-mail manual sem registro.
**Recomendação:** **A** — o opt-out é o próprio match (D5) e o registro do atendimento é o estado do subject (`status removed` + `removedAt`); despublicar foto continua o fluxo do C233 (`publicationStatus: removed` pela staff + `ArchivePhotoRemovalBand`/canal — `ArchivePhoto.ts:340-348`, `AGENTS-public.md:5-7`). Sem collection nova, sem PII nova, sem Consent novo para pedido.
**Rejeitadas:** **B** (formulário/collection/Consent novos, fora do appetite e do invariante); **C** (não registra e não é fail-closed).
### D8 — Antiabuso
**Opções:** A) reusar o dono do throttle (`contentEventRateLimit.ts`) com budget parametrizável + same-origin + body cap extraído; B) limiter novo para a feature; C) reusar as constantes atuais como estão.
**Recomendação:** **A** — `checkContentEventRateLimit(key, budget)` mantém o default do beacon (`:17-25,53-73`) e a busca passa budget estrito (`20` requisições / 10 min por IP hasheado; a busca responde a um **oráculo de pertencimento** — testar vetores para descobrir quem está no índice —, então o budget tem de ser bem menor que o de telemetria); `contentEventClientKey` (`:34-41`) continua sendo o único hash de IP (sal por processo, nada persistido); o body reader sai de `content-events/route.ts:47-78` para `boundedRequestBody.ts` e os dois routes consomem o mesmo dono; sem conta e **sem captcha no MVP** (gatilho registrado). O limiter é bar-raiser, não fronteira de segurança — o gate real é o threshold + Consent.
**Rejeitadas:** **B** (segundo limiter = twin); **C** (120/10min é frouxo demais para biometria); captcha adiado com gatilho (custo de UX/dependência de terceiro).
### D9 — UI e extensão do design (trigger do `designer`)
**Opções:** A) `designer` estende/adapta o artefato **antes** do markup (trigger a/b) e faz a crítica final renderizada (trigger c), com port classe-a-classe; B) portar o artefato literal; C) redesenhar.
**Recomendação:** **A** — o artefato cobre 8 cenas, mas falta o recorte de execução: (i) **cena 6 no modo A/C** — o vazio honesto precisa dizer que a busca cobre quem autorizou participar do índice (sem prometer “todas as fotos”); (ii) **resultado sem “Quem aparece”** (a cena 5 já não mostra, mas o card do C233 mostra — a variante C234 é nova, `ArchivePhotoCard.tsx:53-55`); (iii) estados novos: engine indisponível (WebGL/WASM off), erro de engine, rate-limited, erro de rede — mensagens honestas (nunca “tente outra foto” genérico, precedente `cardCutout.ts:13-20`); (iv) mapeamento de rota: a cena 2 (modal sobre o álbum) vira a página `/fotos/encontre` e a cena 1 vira a entrada na `/fotos` (posição do aside validada); (v) opt-out sem match (encaminha ao canal do C233) e confirmação da cena 8; (vi) a11y já desenhada (44px, foco visível, `prefers-reduced-motion`) mantida. Port com os contratos de classe de `archivePhotoClasses.ts` e `ConsentText`; crítica final com screenshots 390/1280; `Design tier:` no PR.
**Rejeitadas:** **B** (o artefato não cobre rota/estados novos — seguir literal é defeito); **C** (design aprovado existe; redesign é desperdício).
### D10 — Verificação por camada (pins e e2e)
**Opções:** A) unit puro + int das fronteiras Payload/HTTP + e2e novo com stub, no manifest/projeto/curado/pin; B) só int; C) e2e de browser para tudo.
**Recomendação:** **A** — **unit:** `tests/unit/faceSearch.unit.spec.ts` (distância/limiar/melhor match; elegibilidade por modelo+hash; view sem nomes; derivação do stub), `tests/unit/faceSearchRequest.unit.spec.ts` (parse fail-closed do body), `tests/unit/faceEnrollPlan.unit.spec.ts`/`faceIndexPlan.unit.spec.ts` (parsers/recibos), `tests/unit/faceCli.unit.spec.ts` (guardas por subprocesso, molde `archiveCatalogCli.unit.spec.ts`), extensão do unit do rate limit (budget) e das keys de consent. **int:** `tests/int/faceSubjectIndex.int.spec.ts` (enrollment grava; index com analyzer stub cria/remove vínculos; removed não casa; curadoria/aprovação intocadas; transação) e `tests/int/faceSearchApi.int.spec.ts` importando o `POST` (precedente `contentEvents.int.spec.ts:40`): sem Consent → recusa; match devolve **só aprovadas**; **sem score e sem nomes**; subject `removed` não casa; foto `approved→removed` some; opt-out zera vetor e não casa mais; rate limit; same-origin; body inválido. **e2e:** `tests/e2e/frontendFotosSelfie.e2e.spec.ts` (serial) com o engine stubado (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`, seam `faceStubDescriptorFromBytes` compartilhado entre stub e spec — fixtures sem rede), seeds via REST admin (Consent novo, subject, fotos, global) e cleanup; projeto Playwright `frontendFotosSelfie` com `dependencies: ['frontendFotos']` nos **dois** modos (serializa contra o flip do kill switch do álbum; o C233 liga/desliga o global em `frontendFotos.e2e.spec.ts:335-345`); manifest: `frontendFotosSelfie` na entry do C233 (`e2e-affected-manifest.mjs:204-225`) e em `src/lib/schemas`/`src/utilities/content` (`:448-460,493-552`), entry nova com `src/app/(frontend)/api/fotos`, `src/lib/faceSearch`, `src/utilities/faceSubjects`, `src/collections/FaceSubject.ts`, `src/collections/Consent.ts`; curado `E2E_CURATED_SPECS:24-58` + pin `tests/unit/e2eAffectedManifest.unit.spec.ts:54-89` (a migration torna o PR high-risk → conjunto curado). Os scripts/planos novos entram em `SCRIPTS_SPEC_PINNED`.
**Rejeitadas:** **B** (o contrato HTTP público de biometria pede prova real); **C** (browser para lógica pura é caro e frágil).
## Fases verificáveis
1. **Tracer / schema+server — quota ~40%:** migration `add_face_subject` (collection + join + marker + flag) com SQL revisado + `generate:types`; keys + `src/lib/faceSearch.ts` + schema zod; reads/enrollment/index writes; `POST /api/fotos/selfie`; guard do `Consent.ts`; budget do rate limit + extração do body cap. Unit + int verdes antes de seguir. Gate: `pnpm gate:fast` + int do caminho.
2. **CLI ops — quota ~20%:** spike do engine node-wasm (`face-api.node-wasm.js` + tfjs wasm backend em Node, 1 foto de fixture) **antes** de construir o CLI; `copy-face-vision-assets.mjs` + hooks (`package.json`, `Dockerfile`), `faceEnrollPlan.mjs`/`faceIndexPlan.mjs`, `faces:enroll`/`faces:index` com guardas e recibo; unit dos parsers/guardas; `--verify`/plano contra o banco do worktree. Gate: `pnpm gate:fast`.
3. **UI/Design — quota ~30%:** dispatch do `designer` (trigger a/b) e extensão do artefato; components `SelfieSearch*` + engine + stub; `/fotos/encontre` + entrada na `/fotos`; port classe-a-classe; e2e local `--no-deps --project=frontendFotosSelfie`; crítica final (trigger c) com screenshots 390/1280.
4. **Gates/entrega — quota ~10%:** spec e2e + projeto + manifest + curado + pin; `pnpm gate:fast`, int, e2e local; `/simplify` + `capture-review-debts`; docs (`AGENTS-public.md`, runbook C234, `.env.example`, `.gitignore`, changelog); `pnpm push` → PR Ready com `Closes #1369`, `Design tier` e o status dos hard-stops.
## Rabbit holes / Não escopo (engenharia)
- **“Outras pessoas parecidas”, score/percentual, ranking** — vedados; o shape do view model não tem o campo.
- **Guardar selfie/descriptor do visitante** (galeria, localStorage, cookie, “melhorar o match”) — vedado; vetor só em memória do request.
- **Modo B (indexar rostos anônimos do acervo)** sem DPIA/aval — proibido pelo gate desta entrega.
- **Auto-enrollment público, UI de enrollment, fila de revisão de matches** — adiados com gatilho.
- **Vídeo/tempo real/câmeras no evento, app nativo, API pública de reconhecimento** — fora.
- **Vínculo com `Contact`/leadership, “quem é quem”, CRM de rostos** — invariante.
- **Reusar o índice semântico do C229** (falas) para rostos — dono e modalidade diferentes.
- **Segundo engine, terceiro de reconhecimento, modelo via CDN, `tfjs-node`, inferência server-side da selfie** — rejeitados em D1/D5.
- **Segundo limiter, segundo body cap, segundo mecanismo de Consent** — D8/D6 (editar o dono).
- **Global novo de configuração da busca** — a flag vive no `photoAlbum` (dono das chaves do álbum); sem terceiro global.
- **Paginação/ordenação configurável do resultado, contadores de busca, debug de vetores** — fora do MVP.
## Riscos e mitigação
- **Falso positivo de match (sem score exposto):** threshold estrito 0.45 + melhor match único + só fotos aprovadas + canal de remoção + opt-out; a assimetria é honesta (o pior caso mostra fotos públicas sem qualquer identificação/nome). Calibrar o threshold no canário de enrollment antes de ligar a flag em produção; gatilho de revisita se a taxa de falso vínculo aparecer em atendimento.
- **Engine node-wasm do CLI não validado:** a rota `face-api.node-wasm.js` + `@tensorflow/tfjs-backend-wasm` em Node é a aposta do lote; o spike da Fase 2 roda o descriptor sobre a fixture antes de qualquer CLI (fallback documentado: lote via browser headless, rejeitado para o MVP). Se o spike falhar, o plano para em `blocked` — nunca indexa com engine divergente do browser.
- **Troca de modelo invalida o índice:** `model` no subject + `checkedKey` no marker + `--verify` apontando stale; re-enrollment exige as selfies mantidas pela assessoria (fora do site) — registrar no runbook.
- **Engine falha em browser sem WebGL/WASM:** probes antes do download + estado honesto “seu navegador não permite…” (sem culpabilizar a selfie); sem fallback silencioso (precedente `cardCutout.ts:13-20,97-127`).
- **~9MB de bundle/modelos:** import dinâmico só quando o fluxo começa + modelos same-origin baixados com progresso; nunca no bundle inicial da `/fotos`.
- **Lote longo (6,5k fotos, workstation):** `--limit` canário, falha isolada por foto com estágio nomeado, retomada pela marker, recibo com tempo real; rodar fora da janela de deploy.
- **Índice vazio no MVP A/C:** o vazio honesto (cena 6 com recorte A/C) evita promessa falsa; o runbook exige enroll+index antes de ligar a flag; `hasPublishedArchivePhotos` continua dono do link “Fotos”.
- **Oráculo de pertencimento (abuso):** rate limit estrito por IP hasheado + threshold + resposta sem ids/scores; nada lista subjects publicamente.
- **PR high-risk (migration + lockfile):** curado de e2e com `frontendFotosSelfie`/`frontendFotos` + pin atualizado no mesmo PR; e2e local `--no-deps` antes do push.
- **Corrida de e2e com o kill switch do álbum:** projeto com `dependencies: ['frontendFotos']` nos dois modos + linhas próprias + restauração de global/Consent no `afterAll`.
- **Hook de revalidação do `archivePhoto` disparando no CLI:** `revalidateTagSafely` engole fora de request (`documents.ts:27-34`) e o `context: { faceIndex: true }` mantém a curadoria intocada (`ArchivePhoto.ts:168`).
- **Consent apagado por baixo dos subjects:** guard de `beforeDelete` estendido (`Consent.ts:24-90`); texto editado deixa subjects inelegíveis até re-consentimento (fail-closed).
- **Boot com S3 parcial:** mesmo dono de mídia privada; o CLI de index exige mídia espelhada no alvo (`mirroredMediaRequired`).
## Hard-stops (aprovação humana explícita — valem mesmo em `--auto`)
1. **Schema/migration:** `faceSubject` (+ join `matchedPhotos`), grupo hidden `faces` em `archivePhoto` e flag `selfieSearchEnabled` no global `photoAlbum` na migration aditiva `add_face_subject` — `pnpm migrate:create` → SQL revisado à mão (nenhum objeto existente recriado) → `pnpm migrate` **só** no banco do worktree (`teqo_wt<slot>*`) → `pnpm generate:types`; migrations entregues intocadas. **✅ Aprovado na sessão `--auto` (2026-09-27).**
2. **Contrato público/URL e shapes:** `/fotos/encontre` (noindex; 404 sem flag/publicado, 200 com o estado fechado sem Consent), `POST /api/fotos/selfie` com body `{ vector: number[128], intent: 'search'|'leave-index' }` (JSON ≤4KB; sem imagem) e envelopes `{ ok, found, photos }`/`{ ok, removed }` sem score/nomes, com 404 (fechado) e 503 (Consent ausente) como recusas; entrada na `/fotos` gated pela flag. **✅ Aprovado na sessão `--auto` (2026-09-27).**
3. **Consent/DPIA/chaves e política de nascer fechado:** keys `busca-selfie-fotos` e `busca-selfie-indice`, **texto não seedado**, flag default `false`, índice A/C (só consentidos; rostos anônimos proibidos) e **aval jurídico + DPIA antes de ligar em produção** (timing eleitoral, eleição 04/10/2026); o runbook de abertura documenta o ato. **✅ Aprovado na sessão `--auto` (2026-09-27).**
4. **Dependência nova de engine + assets (disclose):** `@vladmandic/face-api@1.7.15` (MIT) + `@tensorflow/tfjs` + `@tensorflow/tfjs-backend-wasm` no `package.json`/lockfile (lockfile = high-risk), com ~9MB de modelos gitignored em `public/fotos/modelos/` servidos same-origin (sem CDN, sem terceiro em runtime); `copy-face-vision-assets.mjs` no `prebuild`/`predev`/Dockerfile. **✅ Aprovado na sessão `--auto` (2026-09-27).**
5. **Enrollment ops-only vs UI:** enrollment e indexação só por CLI (`faces:enroll`/`faces:index`); auto-enrollment público e UI de admin **fora do MVP** — auto-enrollment rejeitado por ausência de verificação de identidade (consentimento de terceiro + DoS de opt-out); UI adiada com gatilho. **✅ Aprovado na sessão `--auto` (2026-09-27).**
## Adiado com gatilho
- **Refatoração do `SelfieSearchFlow`** — 886 linhas/15 estados com render inline; `LeaveConfirmDialog` reimplementa o chassi do kit (`ui/dialog`, Radix); intents/envelopes redigitados em vez de derivar de `lib/faceSearch`/rota. **Gatilho:** próximo toque de comportamento no fluxo (novo estado/envelope), 2º diálogo nas fotos ou requisito de a11y em modal.
- **2ª cópia do copiador de assets de visão** — `copy-face-vision-assets.mjs` quase verbatim de `copy-card-vision-assets.mjs`. **Gatilho:** 3ª cópia `copy-*-vision-assets` — extrair o loop `stat`+size+write para `scripts/lib/`.
- **Recibos/`reportStamp` por CLI de lote** — 3ª cópia (`archiveCatalogPlan`/C232 + `faceEnrollPlan` + `faceIndexPlan`) com `writeReport` quase verbatim nos scripts. **Gatilho:** próximo CLI de lote/recibo — extrair `reportStamp`+`writeReport` para `scripts/lib/cli.mjs` junto do `writeRepoFile`.
- **Reader nomeado do global `photoAlbum`** — residual (comentário de leitura pública já no ponto da rota). **Gatilho:** 3º consumidor direto do global (hoje rota `/api/fotos/selfie` + 2 RSCs via `getCachedGlobal`).
- **Workspace único por run no `faces:index`** — hoje `mkdtemp`/`rm` por foto; mesmo gatilho do bullet de índice incremental (tempo do lote acima do canário ou aprovadas > ~1k).
- **UI de enrollment no admin** — gatilho: assessoria precisar operar sem CLI (volume de adesões ou equipe sem terminal).
- **Captcha/anti-bot** — gatilho: padrão de abuso/comportamento de oráculo observado (contadores, não conteúdo).
- **Modo B (índice de rostos do acervo com DPIA)** — gatilho: DPIA + aval jurídico documentados.
- **Re-embedding automático em troca de modelo** — gatilho: troca do `FACE_SEARCH_MODEL` (vira o item de runbook de migração de índice).
- **Índice incremental otimizado / sharding por município** — gatilho: tempo do lote materialmente acima do canário ou aprovadas > ~1k.
- **Revisão/auditoria de matches pela assessoria (admin)** — gatilho: primeira queixa de falso vínculo ou pedido do jurídico.
- **Extração do registry de consent keys para módulo neutro** — gatilho: 3º fluxo público de consentimento ou o nome passar a mentir.
- **Extração do rate limit/body cap para módulo neutro** — gatilho: 3ª superfície anônima consumindo o mesmo dono.
- **Upload consentido da selfie com retenção zero** — gatilho: cobertura on-device provar-se insuficiente para o público-alvo **e** jurídico aprovar o caminho (hoje: rejeitado).
- **Paginação/filtros do resultado** — gatilho: pessoas com mais de 60 fotos aprovadas no índice.
## Fechamento (sessão `--auto`, 2026-09-27)
- **Hard-stops:** os cinco aprovados explicitamente pelo humano na sessão (schema/migration, contrato público/URL e shapes, Consent/DPIA/chaves com texto não seedado, dependência nova de engine + assets, enrollment ops-only).
- **Desvios registrados na execução:** (1) `faces:index` ficou **sem `--subject`** (canário = `--limit`; qualquer enrollment invalida a revisão de todas as fotos, então o filtro por sujeito economizava pouco e adicionava estado) e o `--verify` ganhou o breakdown de vínculos por sujeito; (2) a recusa do endpoint usa **404** para flag/publicação fechadas e **503** para Consent ausente; (3) o rate limit ganhou **namespace de chave** (`selfie:<hash>`) sobre o dono compartilhado — o beacon e a busca não consomem mais o mesmo contador; (4) o miolo do card virou `ArchivePhotoCardBody` (dono C233) e a variante C234 só troca o href e omite a linha de terceiros; (5) `enrolled_at` (não `updated_at`) é a revisão do sujeito na `checkedKey` — o lote escreve `matchedPhotos` e avançaria o `updated_at` para sempre.
- **Design (trigger c): PARIDADE CERTIFICADA** pelo `designer` (tier primário `openai/gpt-5.6-sol`, sem `DEGRADED`) em 3 rodadas contra o app renderizado (390/1280): faixa de entrada, consentimento, escolha, processamento, resultado (grade larga, superfície branca, título 34px), presença, diálogo, vazio e erro de engine. Ajustes da crítica aplicados: `border-0 pb-0` nos títulos (o `h2` global injeta borda), resultado full-bleed branco com a faixa creme de presença contrastando, scroll/foco no início de cada passo, submit do álbum secundário com a faixa ativa, 44px no "Voltar ao álbum" e no "Voltar sem alterar", copies mobile por breakpoint, ícones semânticos (imagem/aparelho/lixeira), `aria-label` distinto no backdrop do diálogo.
- **`/simplify` + débitos:** dois revisores (estrutural + qualidade) rodados no diff; fixes aplicados (namespace do rate limit, card body compartilhado, foco/scroll por passo, projeção enxuta do subject, dedup de fixtures int, uso do dono `waitForStreamSettled` no e2e novo, remoção de código morto). Triagem `capture-review-debts`: zero `expensive_lock` ≥4 → nenhuma Issue nova; baratos deferidos na seção "Adiado com gatilho" abaixo.
## Aceite de engenharia
- [ ] Aceite de produto coberto: com Consent + flag + adesão, o visitante recebe só as fotos aprovadas em que aparece (sem terceiros/score); sem Consent (ou sem flag) recusa honesta na UI e no servidor; selfie nunca sobe; remoção/opt-out efetivos e registrados; antiabuso ativo; A/C estrito (rostos anônimos fora).
- [ ] Invariantes AGENTS/engineering-standards: sem cadastro paralelo/PII desnecessária; Consent por key estável fail-closed; escrita multi-collection em transação com `req`; bypasses documentados (`overrideAccess` no CLI/na rota); `import 'server-only'`; identificadores em inglês e copy pt-BR; editar o dono (rate limit, body cap, guard do Consent), sem twins.
- [ ] Migration aditiva gerada, revista à mão, aplicada local e registrada; `pnpm generate:types`; nenhuma migration retroeditada.
- [ ] Testes de domínio previstos (unit/int) onde o contrato/shape/transações mudam; e2e novo no disco + projeto Playwright + `E2E_AFFECTED_MANIFEST` + `E2E_CURATED_SPECS` + pin + `SCRIPTS_SPEC_PINNED`.
- [ ] UI portada classe-a-classe do artefato estendido pelo `designer` (triggers a/b), crítica final (c) com screenshots 390/1280 e `Design tier:` no PR.
- [ ] `pnpm gate:fast` verde; int do caminho verde; e2e `frontendFotosSelfie`/`frontendFotos` verde local; runbook + `AGENTS-public.md` + `.env.example` + `.gitignore` + changelog no fechamento; hard-stops com aprovação explícita antes do push.
## Self-score (decision-quality)
**Self-score decision-quality: 4/5.** (1) todas as decisões caras (engine, schema/marker/kill switch, enrollment ops, lote, contrato de consulta, consent, remoção, antiabuso, UI, verificação) têm Opções + Recomendação + Rejeitadas; (2) cabe no appetite: uma collection nova, uma migration aditiva, uma rota pública nova, reuso dos donos existentes (approval gate/media privada/transação/consent/guardas de CLI) e o lote é máquina, não código; (3) rabbit holes nomeados (parecidos/score, retenção de selfie, modo B, auto-enrollment, segundo limiter/engine, C229, CRM de rostos); (4) depth check: edita donos reais (`contentEventRateLimit`, body cap do beacon, guard do `Consent`, global `photoAlbum`, hook de revalidação do `ArchivePhoto`) e só cria módulos onde não há dono (`faceSubjects/`, `lib/faceSearch`, rotas); (5) outcome preservado — fail-closed, A/C, sem PII desnecessária, sem score, sem reescrever o aceite. O que impede o 5: o **threshold 0.45** e a qualidade do engine são aposta não medida (calibração só no canário), e a **operação de re-enrollment na troca de modelo** depende das selfies mantidas pela assessoria fora do site — ambos registrados como risco com gatilho, não escondidos.

View file

@ -60,7 +60,7 @@
color: white;
font-size: 14px;
font-weight: 800;
box-shadow: 0 8px 18px rgb(228 16 47 / 18%);
box-shadow: 0 4px 0 #7f1616;
}
.secondary {
display: inline-flex;
@ -76,6 +76,22 @@
font-size: 14px;
font-weight: 800;
}
.primary:focus-visible,
.secondary:focus-visible {
outline: 3px solid var(--brand-yellow);
outline-offset: 3px;
}
.revision-badge {
display: inline-flex;
align-items: center;
min-height: 24px;
border-radius: 999px;
background: #eef4fb;
padding: 4px 9px;
color: #184e92;
font-size: 11px;
font-weight: 800;
}
.icon {
width: 18px;
height: 18px;
@ -189,6 +205,20 @@
<rect x="3" y="11" width="18" height="10" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</symbol>
<symbol id="wifi-off" viewBox="0 0 24 24">
<path
d="m2 2 20 20M8.5 8.5A12.4 12.4 0 0 1 21 9M3 9a16 16 0 0 1 2.8-1.6M6.5 13a8 8 0 0 1 6.9-1.7M17.5 13a8 8 0 0 1 .5.4M9.5 16.5a4 4 0 0 1 5 0M12 20h.01"
/>
</symbol>
<symbol id="clock" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</symbol>
<symbol id="refresh" viewBox="0 0 24 24">
<path
d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6.4 6.4L4 9M5.5 15A7 7 0 0 0 17.6 17.6L20 15"
/>
</symbol>
</svg>
<main class="mx-auto flex max-w-[1480px] flex-col gap-16 px-4 py-10 md:px-8">
@ -198,6 +228,9 @@
>C234 · gate hi-fi</span
><span class="rounded-full border border-border bg-white px-3 py-1 text-xs font-bold"
>Fluxo sensível · LGPD fail-closed</span
><span
class="rounded-full border border-[#184e92]/20 bg-[#eef4fb] px-3 py-1 text-xs font-extrabold text-[#184e92]"
>Estendido para a execução · 27 set 2026</span
>
</div>
<h1 class="display mt-4 text-3xl font-black tracking-[-.03em]">
@ -207,14 +240,35 @@
A pessoa parte do álbum, entende o uso de biometria, escolhe a própria selfie e recebe
apenas fotos públicas aprovadas em que aparece. A selfie permanece no dispositivo.
</p>
<div class="mt-5 rounded-xl border border-[#184e92]/15 bg-[#f9faff] p-4 text-sm leading-6">
<p class="font-extrabold text-[#184e92]">Deltas de execução desta revisão</p>
<p class="mt-1 text-muted-foreground">
A entrada agora respeita o hero e a grade reais do C233; o fluxo é uma página própria em
<b class="text-foreground">/fotos/encontre</b>; resultado, vazio A/C, falhas do
processamento e opt-out sem reconhecimento ganham estados explícitos. Cenas revisadas
mantêm o número aprovado e recebem selo azul; cenas novas começam em 09.
</p>
</div>
</header>
<!-- CENA 01 -->
<!-- Port literal da entrada: rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-6 py-5;
CTA: inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] bg-(--pt-red)
px-4 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-white
shadow-[0_4px_0_#7f1616] + ARCHIVE_PHOTO_FOCUS. -->
<section aria-labelledby="s1">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 01</p>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 01</p>
<span class="revision-badge">Revisada · C234 execução</span>
</div>
<h2 id="s1" class="display mt-1 text-xl font-black">
Desktop ~1280 · entrada no contexto da /fotos
Entrada real na /fotos · flag ligada
</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
O hero do C233 permanece inteiro. A entrada ocupa uma faixa própria entre o hero e as
facetas: descoberta forte, sem competir com o título do álbum nem transformar o hero em
duas colunas.
</p>
<div class="stage mt-4 overflow-x-auto rounded-2xl border border-border bg-white">
<div class="min-h-[660px] min-w-[1180px] bg-white">
<header class="bg-[#ae1603]">
@ -226,59 +280,164 @@
class="absolute top-[-29.5px] left-[-22.4px] h-auto w-[189px] max-w-none"
/>
</div>
<span class="text-sm font-bold text-white">Fotos</span>
<nav class="flex items-center gap-6 text-sm font-bold text-white">
<span>Início</span><span class="border-b-2 border-[#ffeb00] py-2">Fotos</span
><span>Conteúdos</span>
</nav>
</div>
</header>
<div class="mx-auto max-w-6xl px-8 py-9">
<nav class="text-sm text-muted-foreground">
Início <span class="px-1">/</span> Fotos
</nav>
<div class="mt-6 grid grid-cols-[1fr_420px] items-stretch gap-6">
<div class="rounded-2xl bg-[#fff8f2] p-8">
<p class="text-xs font-black tracking-[.1em] text-[#e4102f] uppercase">
Álbum público
<section
class="relative isolate overflow-hidden border-b-4 border-[#ffeb00] bg-[radial-gradient(circle_at_78%_0%,rgb(255_230_7/13%),transparent_28%),linear-gradient(112deg,#e4102f_0%,#b8102a_58%,#184e92_100%)] text-white"
>
<div class="mx-auto flex min-h-[230px] max-w-6xl items-center px-8 py-10">
<div class="max-w-2xl">
<p class="text-xs font-black tracking-[.12em] text-[#ffeb00] uppercase">
Memória da nossa caminhada
</p>
<h3 class="display mt-3 text-[38px] leading-[1.02] font-black tracking-[-.03em]">
Procure uma foto por data, lugar ou atividade.
<h3 class="display mt-3 text-[42px] leading-none font-black tracking-[-.03em]">
Fotos que contam onde a gente esteve.
</h3>
<p class="mt-4 max-w-lg text-base leading-6 text-muted-foreground">
Use os filtros para navegar pelos registros aprovados da caminhada.
<p class="mt-4 max-w-xl text-base leading-6 text-white/90">
Procure uma agenda, um município ou uma pessoa pública e abra cada registro com
data e contexto.
</p>
</div>
</div>
</section>
<main class="mx-auto max-w-6xl px-8 py-8">
<aside
class="grid grid-cols-[1fr_auto] items-center gap-6 rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-6 py-5"
>
<div class="flex items-start gap-4">
<div
class="mt-7 flex h-12 items-center gap-3 rounded-[10px] border border-black/20 bg-white px-4 text-sm text-muted-foreground"
class="grid size-11 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white"
>
<svg class="icon"><use href="#image" /></svg>Buscar no álbum…
<svg class="icon size-5"><use href="#spark" /></svg>
</div>
<div>
<h3 class="display text-xl font-black">Encontre você nas fotos</h3>
<p class="mt-1 text-sm leading-6 text-muted-foreground">
Use uma selfie sua. A imagem não sai deste dispositivo e a busca cobre quem
autorizou participar do índice.
</p>
</div>
</div>
<aside
class="relative overflow-hidden rounded-2xl bg-[linear-gradient(145deg,#184e92,#0d315f)] p-7 text-white"
<button class="primary shrink-0">
Começar busca por selfie <svg class="icon"><use href="#chev" /></svg>
</button>
</aside>
<div class="mt-8 rounded-2xl border border-border bg-white p-5">
<div
class="flex h-12 items-center gap-3 rounded-[10px] border border-black/20 px-4 text-sm text-muted-foreground"
>
<div
class="absolute -right-12 -bottom-12 size-56 rounded-full border-[30px] border-[#ffeb00]/15"
></div>
<div
class="relative grid size-12 place-items-center rounded-xl bg-white/12 text-[#ffeb00]"
<svg class="icon"><use href="#image" /></svg>Buscar no álbum…
</div>
<div class="mt-4 flex gap-2">
<span
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
>Data</span
>
<svg class="icon size-6"><use href="#spark" /></svg>
</div>
<h3 class="display relative mt-5 text-2xl font-black">Encontre você nas fotos</h3>
<p class="relative mt-2 text-sm leading-6 text-white/80">
Use uma selfie sua. Ela não sai deste dispositivo e não vira cadastro.
</p>
<button class="primary relative mt-7 w-full">
Começar busca por selfie <svg class="icon"><use href="#chev" /></svg>
</button>
<p class="relative mt-3 text-center text-xs text-white/65">
Sem conta · você controla a remoção
</p>
</aside>
<span
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
>Município</span
>
<span
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
>Atividade</span
>
<span
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
>Pessoa pública</span
>
</div>
</div>
<div class="mt-7 grid grid-cols-4 gap-4">
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<div class="mt-7 grid grid-cols-3 gap-4">
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
</div>
</main>
</div>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-[390px_1fr]">
<div>
<p class="text-sm font-extrabold">Mobile 390 · flag ligada</p>
<div
class="stage mt-3 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
>
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-5 text-white">
<span class="display font-black">Jorge Solla</span
><span class="text-sm font-bold">Fotos</span>
</header>
<div
class="border-b-4 border-[#ffeb00] bg-[linear-gradient(125deg,#e4102f,#184e92)] px-5 py-8 text-white"
>
<p class="text-xs font-black tracking-[.12em] text-[#ffeb00] uppercase">
Nossa caminhada
</p>
<h3 class="display mt-3 text-[30px] leading-[.98] font-black">
Encontre um momento.
</h3>
<p class="mt-3 text-sm leading-5 text-white/90">
Busque pela agenda, pelo lugar ou por quem apareceu.
</p>
</div>
<main class="px-5 py-6">
<aside class="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] p-4">
<div class="flex gap-3">
<div
class="grid size-10 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white"
>
<svg class="icon"><use href="#spark" /></svg>
</div>
<div>
<h3 class="display text-lg font-black">Encontre você nas fotos</h3>
<p class="mt-1 text-xs leading-5 text-muted-foreground">
Sua selfie fica neste aparelho. A busca é para quem aderiu ao índice.
</p>
</div>
</div>
<button class="primary mt-4 w-full">Começar busca por selfie</button>
</aside>
<div
class="mt-6 flex h-12 items-center gap-3 rounded-[10px] border border-black/20 px-4 text-sm text-muted-foreground"
>
<svg class="icon"><use href="#image" /></svg>Buscar no álbum…
</div>
<div class="mt-5 grid grid-cols-2 gap-3">
<div class="photo-placeholder aspect-square rounded-lg"></div>
<div class="photo-placeholder aspect-square rounded-lg"></div>
</div>
</main>
</div>
</div>
<div class="max-w-2xl">
<p class="text-sm font-extrabold">Flag desligada · álbum normal, sem reserva vazia</p>
<div class="mt-3 rounded-2xl border border-border bg-white p-6">
<div
class="rounded-xl border border-dashed border-[#184e92]/30 bg-[#f9faff] p-4 text-sm leading-6 text-muted-foreground"
>
<b class="text-foreground">Regra visual:</b> a faixa inteira não existe. Hero e
facetas se encostam pelo espaçamento normal do C233; não aparece placeholder, aviso
de indisponibilidade nem link no header/rodapé.
</div>
<div
class="mt-5 flex h-12 items-center gap-3 rounded-[10px] border border-black/20 px-4 text-sm text-muted-foreground"
>
<svg class="icon"><use href="#image" /></svg>Buscar no álbum…
</div>
<div class="mt-4 flex gap-2">
<span
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
>Data</span
><span
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
>Município</span
><span
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
>Atividade</span
>
</div>
</div>
</div>
</div>
@ -286,72 +445,101 @@
<!-- CENA 02 -->
<section aria-labelledby="s2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 02</p>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 02</p>
<span class="revision-badge">Revisada · página /fotos/encontre</span>
</div>
<h2 id="s2" class="display mt-1 text-xl font-black">
Desktop ~1280 · aviso de biometria e consentimento explícito
Desktop ~1280 · aviso e consentimento na página própria, não em modal
</h2>
<div
class="stage relative mt-4 min-h-[720px] overflow-hidden rounded-2xl border border-border bg-[linear-gradient(rgb(24_10_9/55%),rgb(24_10_9/55%)),#fff8f2] p-8"
>
<div
class="mx-auto max-w-[570px] rounded-2xl bg-white p-7 shadow-[0_28px_90px_rgb(0_0_0/26%)]"
>
<div class="flex items-start justify-between gap-4">
<div class="grid size-12 place-items-center rounded-xl bg-[#eef4fb] text-[#184e92]">
<svg class="icon size-6"><use href="#shield" /></svg>
<div class="stage mt-4 overflow-x-auto rounded-2xl border border-border bg-white">
<div class="min-h-[720px] min-w-[1180px] bg-[#fff8f2]">
<header class="bg-[#ae1603]">
<div class="mx-auto flex h-16 max-w-6xl items-center justify-between px-8 text-white">
<div class="relative h-[52px] w-[144px] overflow-hidden">
<img
src="../../public/campaign-kit/jorge-solla-negativo.png"
alt="Jorge Solla"
class="absolute top-[-29.5px] left-[-22.4px] h-auto w-[189px] max-w-none"
/>
</div>
<a href="#" class="flex min-h-11 items-center gap-2 text-sm font-bold"
><svg class="icon"><use href="#arrow" /></svg>Voltar ao álbum</a
>
</div>
<button
aria-label="Fechar"
class="grid size-10 place-items-center rounded-full bg-muted"
>
<svg class="icon"><use href="#x" /></svg>
</button>
</div>
<p class="mt-5 text-xs font-black tracking-[.1em] text-[#184e92] uppercase">
Antes da sua selfie
</p>
<h3 class="display mt-2 text-2xl font-black">Sua face é um dado biométrico sensível</h3>
<p class="mt-3 text-sm leading-6 text-muted-foreground">
Com sua autorização, comparamos a sua selfie com o índice permitido do álbum para
mostrar somente fotos públicas aprovadas em que você aparece.
</p>
<div class="mt-5 grid gap-3 rounded-xl bg-[#f9faff] p-4 text-sm">
<div class="flex gap-3">
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#device" /></svg>
<p>
<b>A selfie não sai do dispositivo.</b><br /><span class="text-muted-foreground"
>A imagem é processada aqui e não é guardada na galeria do site.</span
</header>
<div class="mx-auto max-w-[650px] px-8 py-10">
<p class="mb-4 text-center text-sm font-bold text-muted-foreground">
Encontre você nas fotos
</p>
<div class="rounded-2xl border border-border bg-white p-7">
<div class="flex items-start justify-between gap-4">
<div
class="grid size-12 place-items-center rounded-xl bg-[#eef4fb] text-[#184e92]"
>
</p>
</div>
<div class="flex gap-3">
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#lock" /></svg>
<p>
<b>Você pode desistir e pedir remoção.</b><br /><span
class="text-muted-foreground"
>O resultado não mostra nomes de terceiros nem percentual de semelhança.</span
<svg class="icon size-6"><use href="#shield" /></svg>
</div>
<span
class="rounded-full bg-[#eef4fb] px-3 py-1 text-xs font-extrabold text-[#184e92]"
>Passo 1 de 3</span
>
</div>
<p class="mt-5 text-xs font-black tracking-[.1em] text-[#184e92] uppercase">
Antes da sua selfie
</p>
<h3 class="display mt-2 text-2xl font-black">
Sua face é um dado biométrico sensível
</h3>
<p class="mt-3 text-sm leading-6 text-muted-foreground">
Com sua autorização, comparamos a sua selfie com o índice formado por pessoas que
autorizaram participar, para mostrar somente fotos públicas aprovadas em que você
aparece.
</p>
<div class="mt-5 grid gap-3 rounded-xl bg-[#f9faff] p-4 text-sm">
<div class="flex gap-3">
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#device" /></svg>
<p>
<b>A selfie não sai do dispositivo.</b><br /><span
class="text-muted-foreground"
>A imagem é processada aqui e não é guardada na galeria do site.</span
>
</p>
</div>
<div class="flex gap-3">
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#lock" /></svg>
<p>
<b>Você pode desistir e pedir remoção.</b><br /><span
class="text-muted-foreground"
>O resultado não mostra nomes de terceiros nem percentual de
semelhança.</span
>
</p>
</div>
</div>
<label
class="mt-5 flex cursor-pointer items-start gap-3 rounded-xl border border-black/15 p-4 text-sm leading-5"
><span
class="mt-0.5 grid size-5 shrink-0 place-items-center rounded border-2 border-[#184e92] bg-[#184e92] text-white"
><svg class="icon size-3.5"><use href="#check" /></svg></span
><span
>Li e autorizo o uso temporário da minha biometria facial para procurar a mim
mesmo neste álbum.</span
></label
>
<p class="mt-3 text-xs leading-5 text-muted-foreground">
Saiba como tratamos seus dados na
<a href="#" class="font-bold text-[#184e92] underline underline-offset-2"
>Política de Privacidade</a
>.
</p>
<button class="primary mt-6 w-full">Concordar e escolher minha selfie</button>
<a
href="#"
class="mt-3 flex min-h-11 w-full items-center justify-center text-sm font-bold text-muted-foreground"
>Agora não · voltar ao álbum</a
>
</div>
</div>
<label
class="mt-5 flex cursor-pointer items-start gap-3 rounded-xl border border-black/15 p-4 text-sm leading-5"
><span
class="mt-0.5 grid size-5 shrink-0 place-items-center rounded border-2 border-[#184e92] bg-[#184e92] text-white"
><svg class="icon size-3.5"><use href="#check" /></svg></span
><span
>Li e autorizo o uso temporário da minha biometria facial para procurar a mim mesmo
neste álbum.</span
></label
>
<p class="mt-3 text-xs leading-5 text-muted-foreground">
Saiba como tratamos seus dados na
<a href="#" class="font-bold text-[#184e92] underline underline-offset-2"
>Política de Privacidade</a
>.
</p>
<button class="primary mt-6 w-full">Concordar e escolher minha selfie</button>
<button class="mt-3 w-full text-sm font-bold text-muted-foreground">Agora não</button>
</div>
</div>
</section>
@ -414,9 +602,12 @@
<!-- CENA 04 -->
<section aria-labelledby="s4">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 04</p>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 04</p>
<span class="revision-badge">Confirmada · página, não modal</span>
</div>
<h2 id="s4" class="display mt-1 text-xl font-black">
Mobile ~390 · processando sem promessas ou percentuais
Mobile 390 · processando em /fotos/encontre, sem promessas ou percentuais
</h2>
<div
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
@ -437,7 +628,10 @@
<div class="spinner !size-7 !border-[3px]"></div>
</div>
</div>
<h3 class="display mt-7 text-2xl font-black">Procurando você nas fotos aprovadas…</h3>
<p class="mt-7 text-xs font-black tracking-[.08em] text-[#184e92] uppercase">
Passo 3 de 3
</p>
<h3 class="display mt-2 text-2xl font-black">Procurando você nas fotos aprovadas…</h3>
<p class="mt-3 max-w-[290px] text-sm leading-6 text-muted-foreground">
Mantenha esta tela aberta. Sua selfie continua somente neste aparelho.
</p>
@ -450,8 +644,13 @@
</section>
<!-- CENA 05 -->
<!-- Card de resultado porta ARCHIVE_PHOTO_CARD; imagem: aspect-square ... sm:aspect-[4/3];
conteúdo: pt-3, título Exo 2 text-sm/font-black sm:text-base e meta muted. Sem terceiro parágrafo. -->
<section aria-labelledby="s5">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 05</p>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 05</p>
<span class="revision-badge">Revisada · variante sem terceiros</span>
</div>
<h2 id="s5" class="display mt-1 text-xl font-black">
Desktop ~1280 · resultado mínimo: só fotos aprovadas em que você aparece
</h2>
@ -487,33 +686,50 @@
</div>
<div class="mt-7 grid grid-cols-3 gap-x-4 gap-y-7">
<article>
<button class="focus-ring block w-full rounded-xl text-left">
<a href="#" class="focus-ring block w-full rounded-xl text-left">
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<p class="display mt-3 font-black">Plenária pela saúde pública</p>
<p class="mt-1 text-sm text-muted-foreground">
12 set 2026 · Município selecionado · Plenária
</p>
</button>
<div class="pt-3">
<p class="display text-base font-black text-balance">
Plenária pela saúde pública
</p>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
12 set 2026 · Município selecionado · Plenária
</p>
</div>
</a>
</article>
<article>
<button class="focus-ring block w-full rounded-xl text-left">
<a href="#" class="focus-ring block w-full rounded-xl text-left">
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<p class="display mt-3 font-black">Caminhada no centro</p>
<p class="mt-1 text-sm text-muted-foreground">
28 ago 2026 · Município selecionado · Caminhada
</p>
</button>
<div class="pt-3">
<p class="display text-base font-black text-balance">Caminhada no centro</p>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
28 ago 2026 · Município selecionado · Caminhada
</p>
</div>
</a>
</article>
<article>
<button class="focus-ring block w-full rounded-xl text-left">
<a href="#" class="focus-ring block w-full rounded-xl text-left">
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<p class="display mt-3 font-black">Encontro com a comunidade</p>
<p class="mt-1 text-sm text-muted-foreground">
16 ago 2026 · Município selecionado · Reunião
</p>
</button>
<div class="pt-3">
<p class="display text-base font-black text-balance">
Encontro com a comunidade
</p>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
16 ago 2026 · Município selecionado · Reunião
</p>
</div>
</a>
</article>
</div>
<div
class="mt-5 rounded-xl border border-[#184e92]/15 bg-[#f9faff] px-4 py-3 text-xs leading-5 text-[#143c70]"
>
<b>Contrato visual da variante:</b> imagem 4:3, título e contexto do card C233. A
linha “Quem aparece” não é reservada, ocultada nem substituída; ela não existe no
resultado por selfie.
</div>
<div class="mt-10 flex items-center justify-between rounded-xl bg-[#fff8f2] p-5">
<div>
<p class="font-extrabold">Quer controlar sua presença no álbum?</p>
@ -530,11 +746,61 @@
</main>
</div>
</div>
<div class="mt-6">
<p class="text-sm font-extrabold">
Mobile 390 · a mesma variante em grade de duas colunas
</p>
<div
class="stage mt-3 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
>
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
<button aria-label="Voltar">
<svg class="icon"><use href="#arrow" /></svg></button
><span class="text-sm font-bold">Resultado</span><span class="w-[18px]"></span>
</header>
<main class="px-5 py-6">
<h3 class="display text-2xl font-black">Encontramos você nestas fotos.</h3>
<p class="mt-2 text-sm leading-5 text-muted-foreground">
Só registros públicos aprovados, sem nomes de terceiros.
</p>
<button class="secondary mt-4 w-full">Usar outra selfie</button>
<div class="mt-6 grid grid-cols-2 gap-x-3 gap-y-5">
<article>
<a href="#" class="focus-ring block w-full rounded-xl text-left"
><div class="photo-placeholder aspect-square rounded-lg"></div>
<div class="pt-3">
<p class="display text-sm font-black text-balance">
Plenária pela saúde pública
</p>
<p class="mt-1 text-xs leading-4 text-muted-foreground">
12 set 2026 · Município selecionado
</p>
</div></a
>
</article>
<article>
<a href="#" class="focus-ring block w-full rounded-xl text-left"
><div class="photo-placeholder aspect-square rounded-lg"></div>
<div class="pt-3">
<p class="display text-sm font-black text-balance">Caminhada no centro</p>
<p class="mt-1 text-xs leading-4 text-muted-foreground">
28 ago 2026 · Município selecionado
</p>
</div></a
>
</article>
</div>
</main>
</div>
</div>
</section>
<!-- CENA 06 -->
<section aria-labelledby="s6">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 06</p>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 06</p>
<span class="revision-badge">Revisada · índice A/C</span>
</div>
<h2 id="s6" class="display mt-1 text-xl font-black">
Mobile ~390 · vazio honesto, sem “parecidos”
</h2>
@ -550,13 +816,13 @@
<div class="grid size-14 place-items-center rounded-full bg-[#eef4fb] text-[#184e92]">
<svg class="icon size-7"><use href="#image" /></svg>
</div>
<h3 class="display mt-5 text-2xl font-black">Não encontramos você</h3>
<h3 class="display mt-5 text-2xl font-black">Não encontramos fotos suas</h3>
<p class="mt-2 max-w-[310px] text-sm leading-6 text-muted-foreground">
Talvez a luz ou o ângulo tenham dificultado a busca — ou ainda não haja uma foto
pública aprovada sua no álbum.
Esta busca funciona para quem autorizou participar do índice. Também pode não haver
uma foto sua entre os registros públicos aprovados.
</p>
<p class="mt-3 text-sm font-bold">
Não mostramos pessoas parecidas para preencher a tela.
Não mostramos pessoas parecidas nem tentamos adivinhar quem você é.
</p>
<button class="primary mt-7 w-full">Tentar outra foto</button>
<a href="#" class="mt-4 text-sm font-bold text-[#184e92] underline underline-offset-3"
@ -601,7 +867,10 @@
<!-- CENA 08 -->
<section aria-labelledby="s8">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 08</p>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 08</p>
<span class="revision-badge">Revisada · opt-out self-service</span>
</div>
<h2 id="s8" class="display mt-1 text-xl font-black">
Mobile ~390 · opt-out e pedido de remoção com confirmação
</h2>
@ -618,8 +887,8 @@
</p>
<h3 class="display mt-1 text-2xl font-black">Você decide se quer aparecer</h3>
<p class="mt-2 text-sm leading-6 text-muted-foreground">
Escolha o pedido. A equipe registra o atendimento e mantém a proteção fechada até
concluir.
Para sair do índice, confirme sua identidade com uma selfie. Para retirar uma foto
pública, use o canal da equipe responsável pelo acervo.
</p>
<label class="mt-5 flex gap-3 rounded-xl border-2 border-[#184e92] bg-[#f9faff] p-4"
><span
@ -632,15 +901,17 @@
></span
></label
>
<label class="mt-3 flex gap-3 rounded-xl border border-border p-4"
><span class="mt-0.5 size-5 shrink-0 rounded-full border-2 border-stone-400"></span
<a
href="#"
data-source="photoAlbum.removalChannelUrl"
class="secondary mt-3 flex w-full items-start! justify-between! p-4! text-left"
><span
><b class="text-sm">Pedir remoção de uma foto</b
><small class="mt-1 block text-xs leading-5 text-muted-foreground"
>Informe qual imagem não deve continuar pública.</small
>Abre o canal configurado pela equipe responsável pelo acervo.</small
></span
></label
>
><svg class="icon mt-0.5 shrink-0"><use href="#chev" /></svg
></a>
<button class="primary mt-6 w-full">Continuar</button
><a href="#" class="mt-4 block text-center text-sm font-bold text-muted-foreground"
>Cancelar</a
@ -660,8 +931,8 @@
</div>
<h3 class="display mt-4 text-xl font-black">Confirmar saída do índice?</h3>
<p class="mt-2 text-sm leading-6 text-muted-foreground">
Vamos registrar seu pedido. Até o atendimento ser concluído, sua presença permanece
protegida e não deve voltar ao índice.
Vamos reconhecer você neste aparelho e, se houver correspondência, retirar sua
presença do índice de busca.
</p>
<button
class="mt-6 flex min-h-11 w-full items-center justify-center rounded-[10px] bg-[#b42318] px-4 text-sm font-extrabold text-white"
@ -675,6 +946,223 @@
</div>
</div>
</section>
<!-- CENA 09 -->
<section aria-labelledby="s9">
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 09</p>
<span class="revision-badge">Nova · engine indisponível</span>
</div>
<h2 id="s9" class="display mt-1 text-xl font-black">
Navegador sem os recursos necessários · mobile 390
</h2>
<div class="mt-4 grid items-start gap-8 xl:grid-cols-[390px_1fr]">
<div class="stage w-[390px] overflow-hidden rounded-[30px] border border-border bg-white">
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
<button aria-label="Voltar">
<svg class="icon"><use href="#arrow" /></svg></button
><span class="text-sm font-bold">Encontre você</span><span class="w-[18px]"></span>
</header>
<div class="flex min-h-[570px] flex-col items-center justify-center px-6 text-center">
<div class="grid size-14 place-items-center rounded-full bg-[#eef4fb] text-[#184e92]">
<svg class="icon size-7"><use href="#device" /></svg>
</div>
<h3 class="display mt-5 text-2xl font-black">
Este navegador não consegue fazer a busca por selfie
</h3>
<p class="mt-3 max-w-[310px] text-sm leading-6 text-muted-foreground">
Para proteger sua imagem, o reconhecimento precisa rodar neste aparelho. Este
navegador não oferece os recursos necessários.
</p>
<p class="mt-3 text-sm font-bold">
O problema não é com a sua selfie. Nenhuma imagem foi enviada.
</p>
<a href="#" class="primary mt-7 w-full">Voltar ao álbum</a>
<p class="mt-4 text-xs leading-5 text-muted-foreground">
Você pode tentar novamente em outro navegador ou aparelho compatível.
</p>
</div>
</div>
<div>
<p class="text-sm font-extrabold">Desktop 1280 · mesma copy, hierarquia adaptada</p>
<div
class="stage mt-3 min-h-[470px] overflow-hidden rounded-2xl border border-border bg-[#fff8f2]"
>
<header class="flex h-16 items-center justify-between bg-[#ae1603] px-8 text-white">
<span class="display font-black">Jorge Solla</span
><a href="#" class="text-sm font-bold">Voltar ao álbum</a>
</header>
<div class="mx-auto max-w-xl px-8 py-12 text-center">
<div
class="mx-auto grid size-14 place-items-center rounded-full bg-[#eef4fb] text-[#184e92]"
>
<svg class="icon size-7"><use href="#device" /></svg>
</div>
<h3 class="display mt-5 text-2xl font-black">
Este navegador não consegue fazer a busca por selfie
</h3>
<p class="mt-3 text-sm leading-6 text-muted-foreground">
Para proteger sua imagem, o reconhecimento precisa rodar neste aparelho. Este
navegador não oferece os recursos necessários.
</p>
<a href="#" class="primary mt-7">Voltar ao álbum</a>
</div>
</div>
</div>
</div>
</section>
<!-- CENA 10 -->
<section aria-labelledby="s10">
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 10</p>
<span class="revision-badge">Nova · erro do engine</span>
</div>
<h2 id="s10" class="display mt-1 text-xl font-black">
Processamento local interrompido · mobile 390
</h2>
<div
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
>
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
<button aria-label="Voltar">
<svg class="icon"><use href="#arrow" /></svg></button
><span class="text-sm font-bold">Encontre você</span><span class="w-[18px]"></span>
</header>
<div class="flex min-h-[570px] flex-col items-center justify-center px-6 text-center">
<div class="grid size-14 place-items-center rounded-full bg-red-50 text-[#b42318]">
<svg class="icon size-7"><use href="#alert" /></svg>
</div>
<h3 class="display mt-5 text-2xl font-black">Não foi possível processar a busca</h3>
<p class="mt-3 max-w-[310px] text-sm leading-6 text-muted-foreground">
O reconhecimento neste aparelho parou antes de concluir. Sua selfie não foi enviada
nem guardada.
</p>
<button class="primary mt-7 w-full">
<svg class="icon"><use href="#refresh" /></svg>Tentar novamente</button
><a
href="#"
class="mt-4 flex min-h-11 items-center text-sm font-bold text-[#184e92] underline underline-offset-3"
>Voltar ao álbum</a
>
</div>
</div>
</section>
<!-- CENA 11 -->
<section aria-labelledby="s11">
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 11</p>
<span class="revision-badge">Nova · limite de tentativas</span>
</div>
<h2 id="s11" class="display mt-1 text-xl font-black">
Resposta 429 sem linguagem técnica · mobile 390
</h2>
<div
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
>
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
<button aria-label="Voltar">
<svg class="icon"><use href="#arrow" /></svg></button
><span class="text-sm font-bold">Encontre você</span><span class="w-[18px]"></span>
</header>
<div class="flex min-h-[570px] flex-col items-center justify-center px-6 text-center">
<div class="grid size-14 place-items-center rounded-full bg-amber-50 text-amber-800">
<svg class="icon size-7"><use href="#clock" /></svg>
</div>
<h3 class="display mt-5 text-2xl font-black">Muitas tentativas em pouco tempo</h3>
<p class="mt-3 max-w-[305px] text-sm leading-6 text-muted-foreground">
Para proteger este recurso, a busca foi pausada por alguns minutos.
</p>
<p class="mt-3 text-sm font-bold">Espere alguns minutos antes de tentar novamente.</p>
<a href="#" class="primary mt-7 w-full">Voltar ao álbum</a>
</div>
</div>
</section>
<!-- CENA 12 -->
<section aria-labelledby="s12">
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 12</p>
<span class="revision-badge">Nova · falha de rede</span>
</div>
<h2 id="s12" class="display mt-1 text-xl font-black">
Falha de conexão depois do processamento local · mobile 390
</h2>
<div
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
>
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
<button aria-label="Voltar">
<svg class="icon"><use href="#arrow" /></svg></button
><span class="text-sm font-bold">Encontre você</span><span class="w-[18px]"></span>
</header>
<div class="flex min-h-[570px] flex-col items-center justify-center px-6 text-center">
<div class="grid size-14 place-items-center rounded-full bg-[#eef4fb] text-[#184e92]">
<svg class="icon size-7"><use href="#wifi-off" /></svg>
</div>
<h3 class="display mt-5 text-2xl font-black">Não foi possível enviar a busca</h3>
<p class="mt-3 max-w-[310px] text-sm leading-6 text-muted-foreground">
A conexão falhou ao enviar somente os dados necessários para procurar as fotos. Sua
selfie continua neste aparelho.
</p>
<button class="primary mt-7 w-full">
<svg class="icon"><use href="#refresh" /></svg>Tentar enviar novamente</button
><a
href="#"
class="mt-4 flex min-h-11 items-center text-sm font-bold text-[#184e92] underline underline-offset-3"
>Voltar ao álbum</a
>
</div>
</div>
</section>
<!-- CENA 13 -->
<section aria-labelledby="s13">
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 13</p>
<span class="revision-badge">Nova · opt-out sem match</span>
</div>
<h2 id="s13" class="display mt-1 text-xl font-black">
Minha presença · pessoa não encontrada no índice · mobile 390
</h2>
<div
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
>
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
<button aria-label="Voltar">
<svg class="icon"><use href="#arrow" /></svg></button
><span class="text-sm font-bold">Minha presença</span><span class="w-[18px]"></span>
</header>
<div class="flex min-h-[600px] flex-col items-center justify-center px-6 text-center">
<div class="grid size-14 place-items-center rounded-full bg-[#eef4fb] text-[#184e92]">
<svg class="icon size-7"><use href="#shield" /></svg>
</div>
<h3 class="display mt-5 text-2xl font-black">Não encontramos sua presença no índice</h3>
<p class="mt-3 max-w-[310px] text-sm leading-6 text-muted-foreground">
Não foi possível reconhecer uma participação sua no índice com esta selfie. Nenhuma
alteração foi feita.
</p>
<button class="primary mt-7 w-full">Tentar outra selfie</button>
<div class="mt-6 w-full border-t border-border pt-5">
<p class="text-sm leading-6 text-muted-foreground">
Quer retirar uma foto pública mesmo assim?
</p>
<a href="#" data-source="photoAlbum.removalChannelUrl" class="secondary mt-3 w-full"
>Pedir remoção de uma foto</a
>
<p class="mt-2 text-xs leading-5 text-muted-foreground">
Este botão usa o canal configurado no álbum; o artefato não inventa endereço.
</p>
</div>
<a
href="#"
class="mt-4 flex min-h-11 items-center text-sm font-bold text-muted-foreground"
>Voltar ao álbum</a
>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -21,7 +21,7 @@
"license": "UNLICENSED",
"type": "module",
"scripts": {
"prebuild": "node scripts/copy-card-vision-assets.mjs",
"prebuild": "node scripts/copy-card-vision-assets.mjs && node scripts/copy-face-vision-assets.mjs",
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
"db:start": "node scripts/db-start.mjs",
"db:stop": "docker compose -p teqo down",
@ -36,6 +36,8 @@
"reels:ingest": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/reels-ingest.mjs",
"flickr:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ingest-flickr-archive.mjs",
"archive:catalog": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/catalog-archive-photos.mjs",
"faces:enroll": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-subject.mjs",
"faces:index": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/index-archive-faces.mjs",
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
"campaign:staging:test-account": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/bootstrap-staging-test-account.mjs",
"db:check:push-chain": "node scripts/check-push-chain.mjs",
@ -124,8 +126,11 @@
"@payloadcms/ui": "3.82.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@tensorflow/tfjs": "4.22.0",
"@tensorflow/tfjs-backend-wasm": "4.22.0",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"@vladmandic/face-api": "1.7.15",
"ai": "^7.0.47",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View file

@ -218,6 +218,17 @@ export default defineConfig({
dependencies: isProdMode ? [] : ['frontend'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
{
// C234 — the selfie search page (`/fotos/encontre`) and its anonymous
// endpoint. Owns its faceSubject/Consent rows and flips the SAME
// `photoAlbum` global as the C233 spec, so it serializes behind it in
// BOTH modes (the admin project precedent: a shared global cannot run
// concurrently across projects).
name: 'frontendFotosSelfie',
testMatch: /frontendFotosSelfie\.e2e\.spec\.ts/,
dependencies: ['frontendFotos'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
{
name: 'admin',
testMatch: /admin\.e2e\.spec\.ts/,
@ -267,6 +278,13 @@ export default defineConfig({
* the same flag in its "Build for e2e" step.
*/
NEXT_PUBLIC_CARDS_CUTOUT_STUB: process.env.NEXT_PUBLIC_CARDS_CUTOUT_STUB ?? '1',
/*
* C234 — the selfie-search engine (face-api + ~7 MB of models) is
* replaced by a deterministic descriptor in tests: no model download,
* no inference. The real engine is exercised by the CLI canary and
* manually (same contract as S15).
*/
NEXT_PUBLIC_FACE_SEARCH_STUB: process.env.NEXT_PUBLIC_FACE_SEARCH_STUB ?? '1',
/*
* C122 — the fake service-account key lets the agenda Google mirror
* derive real states (synced/disabled/paused) in the server process.

View file

@ -68,12 +68,21 @@ importers:
'@simplewebauthn/server':
specifier: ^13.3.2
version: 13.3.2
'@tensorflow/tfjs':
specifier: 4.22.0
version: 4.22.0(seedrandom@3.0.5)
'@tensorflow/tfjs-backend-wasm':
specifier: 4.22.0
version: 4.22.0(@tensorflow/tfjs-core@4.22.0)
'@vercel/analytics':
specifier: ^1.6.1
version: 1.6.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)
'@vercel/speed-insights':
specifier: ^1.3.1
version: 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)
'@vladmandic/face-api':
specifier: 1.7.15
version: 1.7.15
ai:
specifier: ^7.0.47
version: 7.0.47(zod@4.3.6)
@ -3306,6 +3315,47 @@ packages:
'@tailwindcss/postcss@4.1.18':
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
'@tensorflow/tfjs-backend-cpu@4.22.0':
resolution: {integrity: sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==}
engines: {yarn: '>= 1.3.2'}
peerDependencies:
'@tensorflow/tfjs-core': 4.22.0
'@tensorflow/tfjs-backend-wasm@4.22.0':
resolution: {integrity: sha512-/IYhReRIp4jg/wYW0OwbbJZG8ON87mbz0PgkiP3CdcACRSvUN0h8rvC0O3YcDtkTQtFWF/tcXq/KlVDyV49wmA==}
peerDependencies:
'@tensorflow/tfjs-core': 4.22.0
'@tensorflow/tfjs-backend-webgl@4.22.0':
resolution: {integrity: sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==}
engines: {yarn: '>= 1.3.2'}
peerDependencies:
'@tensorflow/tfjs-core': 4.22.0
'@tensorflow/tfjs-converter@4.22.0':
resolution: {integrity: sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==}
peerDependencies:
'@tensorflow/tfjs-core': 4.22.0
'@tensorflow/tfjs-core@4.22.0':
resolution: {integrity: sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==}
engines: {yarn: '>= 1.3.2'}
'@tensorflow/tfjs-data@4.22.0':
resolution: {integrity: sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w==}
peerDependencies:
'@tensorflow/tfjs-core': 4.22.0
seedrandom: ^3.0.5
'@tensorflow/tfjs-layers@4.22.0':
resolution: {integrity: sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA==}
peerDependencies:
'@tensorflow/tfjs-core': 4.22.0
'@tensorflow/tfjs@4.22.0':
resolution: {integrity: sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg==}
hasBin: true
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@ -3387,6 +3437,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/emscripten@0.0.34':
resolution: {integrity: sha512-QSb9ojDincskc+uKMI0KXp8e1NALFINCrMlp8VGKGcTSxeEyRTTKyjWw75NYrCZHUsVEEEpr1tYHpbtaC++/sQ==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@ -3414,6 +3467,9 @@ packages:
'@types/lodash@4.17.23':
resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==}
'@types/long@4.0.2':
resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@ -3423,12 +3479,21 @@ packages:
'@types/ndarray@1.0.14':
resolution: {integrity: sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==}
'@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
'@types/node@20.3.3':
resolution: {integrity: sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==}
'@types/node@22.19.9':
resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==}
'@types/offscreencanvas@2019.3.0':
resolution: {integrity: sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==}
'@types/offscreencanvas@2019.7.3':
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
@ -3448,6 +3513,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/seedrandom@2.4.34':
resolution: {integrity: sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==}
'@types/shpjs@3.4.7':
resolution: {integrity: sha512-/6PjggpFsq9NFxar6ZpXsnYZ+nQJR8Cv03Gne1enIJuMZ/eFVOpu0orHxL9D7RT3ciJElzF2H6l+49US23ydUw==}
@ -3897,6 +3965,10 @@ packages:
'@vitest/utils@4.0.18':
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
'@vladmandic/face-api@1.7.15':
resolution: {integrity: sha512-WDMmK3CfNLo8jylWqMoQgf4nIst3M0fzx1dnac96wv/dvMTN4DxC/Pq1DGtduDk1lktCamQ3MIDXFnvrdHTXDw==}
engines: {node: '>=14.0.0'}
'@vue/compiler-core@3.5.40':
resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==}
@ -3912,6 +3984,9 @@ packages:
'@vue/shared@3.5.40':
resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==}
'@webgpu/types@0.1.38':
resolution: {integrity: sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==}
'@workflow/serde@4.1.0':
resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==}
@ -3998,6 +4073,9 @@ packages:
app-module-path@2.2.0:
resolution: {integrity: sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==}
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@ -4073,6 +4151,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@ -4269,6 +4350,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@ -4311,6 +4395,10 @@ packages:
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@ -4368,6 +4456,9 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
core-js@3.29.1:
resolution: {integrity: sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
@ -4540,6 +4631,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@ -5158,6 +5253,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
formatly@0.3.0:
resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==}
engines: {node: '>=18.3.0'}
@ -5358,6 +5457,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
@ -5954,6 +6057,9 @@ packages:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'}
long@4.0.0:
resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@ -6181,10 +6287,18 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
@ -6312,6 +6426,15 @@ packages:
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.6.13:
resolution: {integrity: sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -6948,6 +7071,9 @@ packages:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
regexp.prototype.flags@1.5.4:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
@ -7101,6 +7227,9 @@ packages:
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
seedrandom@3.0.5:
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@ -7235,6 +7364,9 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
@ -7512,6 +7644,9 @@ packages:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
@ -7876,6 +8011,9 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
@ -7892,6 +8030,9 @@ packages:
resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@ -8010,10 +8151,18 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@16.2.2:
resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==}
engines: {node: '>=10'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@ -11180,6 +11329,73 @@ snapshots:
postcss: 8.5.6
tailwindcss: 4.1.18
'@tensorflow/tfjs-backend-cpu@4.22.0(@tensorflow/tfjs-core@4.22.0)':
dependencies:
'@tensorflow/tfjs-core': 4.22.0
'@types/seedrandom': 2.4.34
seedrandom: 3.0.5
'@tensorflow/tfjs-backend-wasm@4.22.0(@tensorflow/tfjs-core@4.22.0)':
dependencies:
'@tensorflow/tfjs-backend-cpu': 4.22.0(@tensorflow/tfjs-core@4.22.0)
'@tensorflow/tfjs-core': 4.22.0
'@types/emscripten': 0.0.34
'@tensorflow/tfjs-backend-webgl@4.22.0(@tensorflow/tfjs-core@4.22.0)':
dependencies:
'@tensorflow/tfjs-backend-cpu': 4.22.0(@tensorflow/tfjs-core@4.22.0)
'@tensorflow/tfjs-core': 4.22.0
'@types/offscreencanvas': 2019.3.0
'@types/seedrandom': 2.4.34
seedrandom: 3.0.5
'@tensorflow/tfjs-converter@4.22.0(@tensorflow/tfjs-core@4.22.0)':
dependencies:
'@tensorflow/tfjs-core': 4.22.0
'@tensorflow/tfjs-core@4.22.0':
dependencies:
'@types/long': 4.0.2
'@types/offscreencanvas': 2019.7.3
'@types/seedrandom': 2.4.34
'@webgpu/types': 0.1.38
long: 4.0.0
node-fetch: 2.6.13
seedrandom: 3.0.5
transitivePeerDependencies:
- encoding
'@tensorflow/tfjs-data@4.22.0(@tensorflow/tfjs-core@4.22.0)(seedrandom@3.0.5)':
dependencies:
'@tensorflow/tfjs-core': 4.22.0
'@types/node-fetch': 2.6.13
node-fetch: 2.6.13
seedrandom: 3.0.5
string_decoder: 1.3.0
transitivePeerDependencies:
- encoding
'@tensorflow/tfjs-layers@4.22.0(@tensorflow/tfjs-core@4.22.0)':
dependencies:
'@tensorflow/tfjs-core': 4.22.0
'@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)':
dependencies:
'@tensorflow/tfjs-backend-cpu': 4.22.0(@tensorflow/tfjs-core@4.22.0)
'@tensorflow/tfjs-backend-webgl': 4.22.0(@tensorflow/tfjs-core@4.22.0)
'@tensorflow/tfjs-converter': 4.22.0(@tensorflow/tfjs-core@4.22.0)
'@tensorflow/tfjs-core': 4.22.0
'@tensorflow/tfjs-data': 4.22.0(@tensorflow/tfjs-core@4.22.0)(seedrandom@3.0.5)
'@tensorflow/tfjs-layers': 4.22.0(@tensorflow/tfjs-core@4.22.0)
argparse: 1.0.10
chalk: 4.1.2
core-js: 3.29.1
regenerator-runtime: 0.13.11
yargs: 16.2.2
transitivePeerDependencies:
- encoding
- seedrandom
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
@ -11283,6 +11499,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/emscripten@0.0.34': {}
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@ -11307,6 +11525,8 @@ snapshots:
'@types/lodash@4.17.23': {}
'@types/long@4.0.2': {}
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@ -11315,12 +11535,21 @@ snapshots:
'@types/ndarray@1.0.14': {}
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 22.19.9
form-data: 4.0.6
'@types/node@20.3.3': {}
'@types/node@22.19.9':
dependencies:
undici-types: 6.21.0
'@types/offscreencanvas@2019.3.0': {}
'@types/offscreencanvas@2019.7.3': {}
'@types/parse-json@4.0.2': {}
'@types/pg@8.10.2':
@ -11341,6 +11570,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/seedrandom@2.4.34': {}
'@types/shpjs@3.4.7':
dependencies:
'@types/geojson': 7946.0.16
@ -11721,6 +11952,8 @@ snapshots:
'@vitest/pretty-format': 4.0.18
tinyrainbow: 3.0.3
'@vladmandic/face-api@1.7.15': {}
'@vue/compiler-core@3.5.40':
dependencies:
'@babel/parser': 7.29.7
@ -11753,6 +11986,8 @@ snapshots:
'@vue/shared@3.5.40': {}
'@webgpu/types@0.1.38': {}
'@workflow/serde@4.1.0': {}
accepts@2.0.0:
@ -11832,6 +12067,10 @@ snapshots:
app-module-path@2.2.0: {}
argparse@1.0.10:
dependencies:
sprintf-js: 1.0.3
argparse@2.0.1: {}
aria-hidden@1.2.6:
@ -11942,6 +12181,8 @@ snapshots:
async-function@1.0.0: {}
asynckit@0.4.0: {}
atomic-sleep@1.0.0: {}
available-typed-arrays@1.0.7:
@ -12134,6 +12375,12 @@ snapshots:
client-only@0.0.1: {}
cliui@7.0.4:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@ -12178,6 +12425,10 @@ snapshots:
colorette@2.0.20: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}
@ -12212,6 +12463,8 @@ snapshots:
cookie@1.1.1: {}
core-js@3.29.1: {}
cors@2.8.6:
dependencies:
object-assign: 4.1.1
@ -12364,6 +12617,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
delayed-stream@1.0.0: {}
depd@2.0.0: {}
dependency-tree@11.5.0:
@ -13178,6 +13433,14 @@ snapshots:
dependencies:
is-callable: 1.2.7
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
formatly@0.3.0:
dependencies:
fd-package-json: 2.0.0
@ -13371,6 +13634,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
@ -13923,6 +14190,8 @@ snapshots:
chalk: 5.6.2
is-unicode-supported: 1.3.0
long@4.0.0: {}
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@ -14387,8 +14656,14 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
@ -14515,6 +14790,10 @@ snapshots:
node-domexception@1.0.0: {}
node-fetch@2.6.13:
dependencies:
whatwg-url: 5.0.0
node-fetch@3.3.2:
dependencies:
data-uri-to-buffer: 4.0.1
@ -15324,6 +15603,8 @@ snapshots:
get-proto: 1.0.1
which-builtin-type: 1.2.1
regenerator-runtime@0.13.11: {}
regexp.prototype.flags@1.5.4:
dependencies:
call-bind: 1.0.8
@ -15531,6 +15812,8 @@ snapshots:
secure-json-parse@4.1.0: {}
seedrandom@3.0.5: {}
semver@6.3.1: {}
semver@7.7.4: {}
@ -15777,6 +16060,8 @@ snapshots:
split2@4.2.0: {}
sprintf-js@1.0.3: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
@ -16056,6 +16341,8 @@ snapshots:
dependencies:
tldts: 7.0.23
tr46@0.0.3: {}
tr46@6.0.0:
dependencies:
punycode: 2.3.1
@ -16440,6 +16727,8 @@ snapshots:
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}
webidl-conversions@8.0.1: {}
whatwg-mimetype@3.0.0: {}
@ -16454,6 +16743,11 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@ -16572,8 +16866,20 @@ snapshots:
yaml@2.9.0: {}
yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {}
yargs@16.2.2:
dependencies:
cliui: 7.0.4
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 20.2.9
yargs@17.7.2:
dependencies:
cliui: 8.0.1

View file

@ -0,0 +1,56 @@
#!/usr/bin/env node
/**
* C234 — copies the `@vladmandic/face-api` model weights into
* `public/fotos/modelos/` so the selfie search runs 100% same-origin (no CDN
* executing in the visitor's browser; the e2e suite never touches the network).
*
* Same contract as `copy-card-vision-assets.mjs` (S15): the models are a
* gitignored build artifact — this script runs in `pnpm dev` (preflight), in
* `pnpm build` (npm `prebuild`) and in the Docker builder before `next build`.
* Idempotent and cheap: a file already present with the same byte size is
* skipped. The list must match the nets `faceSearchEngine.ts` (browser) and
* `scripts/lib/faceEngine.mjs` (CLI) load.
*/
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
export const FACE_MODEL_FILES = [
'tiny_face_detector_model-weights_manifest.json',
'tiny_face_detector_model.bin',
'face_landmark_68_model-weights_manifest.json',
'face_landmark_68_model.bin',
'face_recognition_model-weights_manifest.json',
'face_recognition_model.bin',
]
export const copyFaceVisionAssets = async (repoRoot) => {
const sourceDir = path.join(repoRoot, 'node_modules/@vladmandic/face-api/model')
const targetDir = path.join(repoRoot, 'public/fotos/modelos')
await mkdir(targetDir, { recursive: true })
for (const name of FACE_MODEL_FILES) {
const source = path.join(sourceDir, name)
const target = path.join(targetDir, name)
try {
const [sourceStat, targetStat] = await Promise.all([
stat(source),
stat(target).catch(() => null),
])
if (targetStat && targetStat.size === sourceStat.size) continue
await writeFile(target, await readFile(source))
console.log(`[copy-face-vision-assets] ${name}`)
} catch (error) {
throw new Error(
`[copy-face-vision-assets] failed to copy ${name}: ${error instanceof Error ? error.message : error}`,
)
}
}
}
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isDirectRun) {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
await copyFaceVisionAssets(repoRoot)
}

View file

@ -15,6 +15,7 @@ import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyCardVisionAssets } from './copy-card-vision-assets.mjs'
import { copyFaceVisionAssets } from './copy-face-vision-assets.mjs'
import './guard-dev-db.mjs'
import { dieWithLabel, loadCliEnv, nextDevArgs, resolveDevPort } from './lib/cli.mjs'
@ -27,6 +28,7 @@ loadCliEnv()
// server serves it same-origin before Next boots.
try {
await copyCardVisionAssets(process.cwd())
await copyFaceVisionAssets(process.cwd())
} catch (error) {
die(error.message)
}

View file

@ -0,0 +1,224 @@
/**
* C234 — enrolls one person in the selfie-search index (A/C scope of the gate,
* PR #1370): the assessoria runs this WITH the person, after the signed
* adherence consent, and the row is the only place a consented descriptor
* exists. The selfie is read from disk, the descriptor is computed locally by
* the same face-api nets the browser runs (only ONE face is accepted) and the
* image is never stored or uploaded anywhere.
*
* Modes:
* pnpm faces:enroll --label "<nome>" --selfie <arquivo>
* plan/dry-run (default) — detects the face,
* resolves the consent and reports; no write
* pnpm faces:enroll ... --apply writes the faceSubject row; requires
* FACE_ENROLL_CONFIRM=1 outside local dev and
* the declared TEQO_ENV matching the database
*
* Options: --subject <id> (re-enrollment: replaces the descriptor, advances
* `enrolledAt` and clears the derived links), --out <dir> (receipts, default
* `data/face`), --help.
*
* Guards: fails closed when the index Consent (stable key) is not configured,
* when the selfie has 0 or more than 1 detectable face, and on a non-local
* target without the intent flag + declared environment. The receipt never
* carries the descriptor nor the image.
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getPayload } from 'payload'
import { FACE_INDEX_CONSENT_KEY } from '../src/lib/campaignConsentKeys.ts'
import { FACE_SEARCH_MODEL } from '../src/lib/faceSearch.ts'
import {
TEQO_ENV_DATABASE_BY_ENV,
assertEnvironmentDatabaseTarget,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
loadCliEnv,
writeRepoFile,
} from './lib/cli.mjs'
import { createNodeFaceEngine } from './lib/faceEngine.mjs'
import {
FACE_ENROLL_DEFAULT_OUT_DIR,
faceEnrollReportStamp,
formatFaceEnrollReport,
parseFaceEnrollCliArgs,
} from './lib/faceEnrollPlan.mjs'
loadCliEnv()
const die = dieWithLabel('faces:enroll')
const WRITE_CONFIRM_FLAG = 'FACE_ENROLL_CONFIRM'
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
.map(([environment, database]) => `${environment} (${database})`)
.join(' | ')
const HELP = `
Uso: pnpm faces:enroll --label "<nome>" --selfie <arquivo> [opções]
Inscreve uma pessoa no índice da busca por selfie (/fotos/encontre). A selfie é
lida do disco e processada localmente (mesmos modelos do navegador): exatamente
1 rosto é exigido. O vetor fica no servidor; a imagem nunca é guardada.
Modos:
(sem flag) plano/dry-run — detecta o rosto e resolve o consentimento; sem escrita
--apply grava a pessoa no índice (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local)
Opções:
--label <nome> nome interno da pessoa (nunca aparece no site)
--selfie <arquivo> foto com um único rosto
--subject <id> re-enrollment: substitui o vetor e limpa os vínculos derivados
--out <dir> diretório dos recibos (default ${FACE_ENROLL_DEFAULT_OUT_DIR})
--help esta ajuda
Ambiente:
DATABASE_URL alvo (obrigatório em todos os modos)
--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines}
casando o banco exato. O consentimento de adesão (chave ${FACE_INDEX_CONSENT_KEY}) precisa
existir no admin; sem ele o fluxo falha fechado.
`
const writeReport = async (options, report, suffix = '') => {
const stamp = faceEnrollReportStamp(report.runAt)
const relativePath = join(options.out, 'reports', `face-enroll-${stamp}${suffix}.json`)
const body = JSON.stringify(report, null, 2)
await writeRepoFile({
label: 'faces:enroll',
root: process.cwd(),
relativePath,
body,
})
return join(process.cwd(), relativePath)
}
async function main() {
const options = parseFaceEnrollCliArgs(process.argv.slice(2))
if (options.help) {
console.log(HELP)
process.exit(0)
}
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
const mode = options.apply ? 'apply' : 'plan'
const runAt = new Date().toISOString()
const startedAt = Date.now()
console.log(`[faces:enroll] alvo: ${databaseTarget()} | modo: ${mode}`)
if (mode === 'apply') {
assertWriteConfirm({
label: 'faces:enroll',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm faces:enroll --label "<nome>" --selfie <arquivo> --apply',
})
assertEnvironmentDatabaseTarget()
}
try {
// Readability probe only: the bytes are decoded by `prepareFaceImage`
// below; this gives the operator a friendly refusal before the engine.
await readFile(options.selfie)
} catch (error) {
die(`não foi possível ler a selfie ${options.selfie}: ${error?.message ?? error}`)
}
const engine = await createNodeFaceEngine(process.cwd()).catch((error) => {
die(error.message)
})
const { prepareFaceImage } =
await import('../src/utilities/faceSubjects/faceSubjectPhotoIndex.ts')
let descriptors
try {
descriptors = await engine.detectPixels(await prepareFaceImage(options.selfie))
} catch (error) {
die(`engine falhou ao processar a selfie: ${error?.message ?? error}`)
}
const baseReport = {
runAt,
mode,
target: databaseTarget(),
model: FACE_SEARCH_MODEL,
label: options.label.trim(),
subjectId: options.subject,
subjectAction: null,
faceCount: descriptors.length,
allowed: descriptors.length === 1,
reason:
descriptors.length === 1
? null
: descriptors.length === 0
? 'nenhum rosto detectado — use uma selfie nítida, de frente e bem iluminada'
: 'mais de um rosto na selfie — use uma foto com apenas uma pessoa',
consentKey: FACE_INDEX_CONSENT_KEY,
consentHash: null,
durationMs: 0,
}
if (!baseReport.allowed) {
const report = { ...baseReport, durationMs: Date.now() - startedAt }
const reportPath = await writeReport(options, report)
for (const line of formatFaceEnrollReport(report)) console.log(line)
console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`)
die('enrollment recusado — detalhe no recibo acima.')
}
const config = (await import('../src/payload.config.ts')).default
const payload = await getPayload({ config })
const { getConsentByKey } = await import('../src/utilities/campaignConsent.ts')
const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY)
if (!consent) {
const report = { ...baseReport, durationMs: Date.now() - startedAt }
await writeReport(options, report)
for (const line of formatFaceEnrollReport(report)) console.log(line)
die(
`consentimento de adesão não configurado (chave ${FACE_INDEX_CONSENT_KEY}) — o fluxo falha fechado.`,
)
}
const consentHash = consent.contentHash
if (mode === 'plan') {
const report = {
...baseReport,
consentHash,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatFaceEnrollReport(report)) console.log(line)
console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`)
process.exit(0)
}
const { enrollFaceSubject } =
await import('../src/utilities/faceSubjects/faceSubjectEnrollment.ts')
const enrolled = await enrollFaceSubject({
payload,
label: baseReport.label,
descriptor: descriptors[0],
consent: { id: consent.id, contentHash: consentHash },
subjectId: options.subject,
})
const report = {
...baseReport,
subjectId: enrolled.id,
subjectAction: enrolled.created ? 'created' : 'updated',
consentHash,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatFaceEnrollReport(report)) console.log(line)
console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`)
process.exit(0)
}
main().catch((error) => {
die(error?.message || String(error))
})

View file

@ -0,0 +1,266 @@
/**
* C234 — indexes the approved photo archive for the selfie search: each photo
* is downloaded from the private storage, prepared small and handed to the SAME
* face-api engine the browser runs; the descriptors are compared against the
* enrolled subjects and thrown away — only the photo↔subject links and the
* photo's revision marker are persisted (A/C scope of the gate, PR #1370: no
* anonymous face index anywhere). Adding/re-enrolling a subject changes the
* revision, so the next run reprocesses every approved photo.
*
* Modes:
* pnpm faces:index plan/dry-run (default) — the queue; no engine, no write
* pnpm faces:index --apply processes the queue; requires
* FACE_INDEX_CONFIRM=1 outside local dev, the
* declared TEQO_ENV and the complete S3_* set
* when the target media is remote
* pnpm faces:index --verify read-only inventory (indexed/stale/consent)
*
* Options: --limit <n> (canary), --refresh (ignores the marker), --out <dir>
* (receipts, default `data/face`), --help.
*
* Runbook: on the workstation, against the stack env — see
* docs/ops/teqo-1313-deploy.md.
*/
import { join } from 'node:path'
import { getPayload } from 'payload'
import { FACE_INDEX_CONSENT_KEY } from '../src/lib/campaignConsentKeys.ts'
import { FACE_SEARCH_MODEL } from '../src/lib/faceSearch.ts'
import {
TEQO_ENV_DATABASE_BY_ENV,
assertEnvironmentDatabaseTarget,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
loadCliEnv,
mirroredMediaRequired,
writeRepoFile,
} from './lib/cli.mjs'
import { createNodeFaceEngine } from './lib/faceEngine.mjs'
import {
FACE_INDEX_DEFAULT_OUT_DIR,
faceIndexReportStamp,
formatFaceIndexInventory,
formatFaceIndexReport,
parseFaceIndexCliArgs,
summarizeFaceIndexInventory,
summarizeFaceIndexResults,
} from './lib/faceIndexPlan.mjs'
loadCliEnv()
const die = dieWithLabel('faces:index')
const WRITE_CONFIRM_FLAG = 'FACE_INDEX_CONFIRM'
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
.map(([environment, database]) => `${environment} (${database})`)
.join(' | ')
const HELP = `
Uso: pnpm faces:index [opções]
Indexa as fotos APROVADAS do acervo para a busca por selfie: por foto, detecta
os rostos com os mesmos modelos do navegador, compara com os descritores das
pessoas inscritas e grava só os vínculos (foto ↔ pessoa) + a revisão da foto.
Os descritores dos rostos do acervo são descartados — não existe índice anônimo.
Modos (mutuamente exclusivos; o default é o plano):
(sem flag) plano/dry-run — fila e revisão; sem engine nem escrita
--apply processa a fila (engine local + escrita transacional)
--verify inventário read-only (indexadas/desatualizadas/consentimento)
Opções:
--limit <n> --apply: processa no máximo n fotos (canário)
--refresh ignora o marcador de revisão e reprocessa tudo
--out <dir> diretório dos recibos (default ${FACE_INDEX_DEFAULT_OUT_DIR})
--help esta ajuda
Ambiente:
DATABASE_URL alvo (obrigatório em todos os modos)
--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1, TEQO_ENV=${envLines}
casando o banco exato e as quatro envs S3_* quando a mídia for remota. O plano e o
--verify são read-only.
`
const writeReport = async (options, report, suffix = '') => {
const stamp = faceIndexReportStamp(report.runAt)
const relativePath = join(options.out, 'reports', `face-index-${stamp}${suffix}.json`)
const body = JSON.stringify(report, null, 2)
await writeRepoFile({
label: 'faces:index',
root: process.cwd(),
relativePath,
body,
})
return join(process.cwd(), relativePath)
}
async function main() {
const options = parseFaceIndexCliArgs(process.argv.slice(2))
if (options.help) {
console.log(HELP)
process.exit(0)
}
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
const mode = options.apply ? 'apply' : options.verify ? 'verify' : 'plan'
const runAt = new Date().toISOString()
console.log(`[faces:index] alvo: ${databaseTarget()} | modo: ${mode}`)
if (mode === 'apply') {
assertWriteConfirm({
label: 'faces:index',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm faces:index --apply',
})
assertEnvironmentDatabaseTarget()
const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts')
const storage = resolveS3StorageEnv(process.env)
if (mirroredMediaRequired({ s3Enabled: storage.enabled })) {
die(
'escrita em produção/remoto exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o acervo não está acessível no alvo.',
)
}
}
const config = (await import('../src/payload.config.ts')).default
const { listArchivePhotoFaceIndexQueue, indexArchivePhotoFaces } =
await import('../src/utilities/faceSubjects/faceSubjectPhotoIndex.ts')
const { listFaceSubjects } = await import('../src/utilities/faceSubjects/faceSubjectReads.ts')
const { getConsentByKey } = await import('../src/utilities/campaignConsent.ts')
const payload = await getPayload({ config })
const startedAt = Date.now()
if (mode === 'verify') {
const [queue, subjects, consent] = await Promise.all([
listArchivePhotoFaceIndexQueue({ payload }),
listFaceSubjects(payload),
getConsentByKey(payload, FACE_INDEX_CONSENT_KEY),
])
const inventory = summarizeFaceIndexInventory({
queue,
subjects,
currentConsentHash: consent?.contentHash ?? null,
})
const report = {
runAt,
mode,
target: databaseTarget(),
model: FACE_SEARCH_MODEL,
...inventory,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report, '-verify')
for (const line of formatFaceIndexInventory(inventory, FACE_SEARCH_MODEL)) console.log(line)
console.log(`\n[faces:index] recibo JSON: ${reportPath}`)
const problems = []
if (!consent) {
problems.push(
`consentimento de adesão ausente (chave ${FACE_INDEX_CONSENT_KEY}) — a busca responde vazio para todos`,
)
}
if (inventory.staleConsentSubjects > 0) {
problems.push(
`${inventory.staleConsentSubjects} sujeito(s) com consentimento vencido — re-consentir e re-enrollar`,
)
}
if (inventory.stale > 0) {
problems.push(`${inventory.stale} foto(s) desatualizada(s) — rode o --apply`)
}
if (problems.length > 0) die(problems.join('; '))
process.exit(0)
}
const queue = await listArchivePhotoFaceIndexQueue({
payload,
refresh: options.refresh,
limit: mode === 'plan' ? undefined : (options.limit ?? undefined),
})
if (mode === 'plan') {
const report = {
runAt,
mode,
target: databaseTarget(),
model: FACE_SEARCH_MODEL,
queue: {
indexKey: queue.indexKey,
totalApproved: queue.totalApproved,
stale: queue.stale,
eligibleSubjects: queue.eligibleSubjects,
items: queue.items.length,
},
summary: { indexed: 0, failed: 0, linkedPhotos: 0, linksBySubject: [], failures: [] },
preview: queue.items.slice(0, options.limit ?? 20).map((item) => ({ photoId: item.id })),
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatFaceIndexReport(report)) console.log(line)
console.log(`\n[faces:index] recibo JSON: ${reportPath}`)
process.exit(0)
}
if (queue.eligibleSubjects === 0) {
die(
'nenhuma pessoa elegível no índice — rode o faces:enroll antes (o lote não tem o que procurar).',
)
}
const engine = await createNodeFaceEngine(process.cwd()).catch((error) => {
die(error.message)
})
const results = []
for (const item of queue.items) {
const result = await indexArchivePhotoFaces({
payload,
item,
indexKey: queue.indexKey,
analyze: engine.detectPixels,
})
results.push({ photoId: item.id, ...result })
const detail =
result.status === 'failed'
? ` [${result.stage}]: ${result.error}`
: result.matchedSubjects.length > 0
? ` → ${result.matchedSubjects.length} vínculo(s)`
: ''
console.log(`[faces:index] ${result.status}${detail} foto ${item.id}`)
}
const summary = summarizeFaceIndexResults(results)
const report = {
runAt,
mode,
target: databaseTarget(),
model: FACE_SEARCH_MODEL,
queue: {
indexKey: queue.indexKey,
totalApproved: queue.totalApproved,
stale: queue.stale,
eligibleSubjects: queue.eligibleSubjects,
items: queue.items.length,
},
summary,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatFaceIndexReport(report)) console.log(line)
console.log(`\n[faces:index] recibo JSON: ${reportPath}`)
if (summary.indexed === 0 && results.length > 0) {
die('nenhuma foto foi processada com sucesso — falhas listadas no recibo.')
}
process.exit(0)
}
main().catch((error) => {
die(error?.message || String(error))
})

View file

@ -55,6 +55,10 @@ export const E2E_CURATED_SPECS = [
// media route) is a new contract and the migration makes every PR of this
// delivery high-risk (curated only).
'frontendFotos',
// C234 — deliberate: the selfie search page/endpoint (Consent fail-closed,
// only consented subjects, only approved photos) is a new contract and the
// migration makes every PR of this delivery high-risk (curated only).
'frontendFotosSelfie',
]
/**
@ -221,7 +225,29 @@ export const E2E_AFFECTED_MANIFEST = [
// The canonical names of the "pessoa pública" facet.
'src/lib/publicFigureCatalog.ts',
],
specs: ['frontendFotos'],
specs: ['frontendFotos', 'frontendFotosSelfie'],
},
{
// C234 — the selfie search: the `/fotos/encontre` page (already matched by
// the C233 prefix above), the anonymous descriptor endpoint, the consented
// subjects and the bounded-body/rate-limit owners it shares. The migration
// makes every PR of this delivery high-risk (curated only).
prefixes: [
'src/app/(frontend)/api/fotos',
'src/lib/faceSearch.ts',
'src/utilities/faceSubjects',
'src/collections/FaceSubject.ts',
'src/collections/Consent.ts',
'src/utilities/campaignConsent.ts',
'src/lib/campaignConsentKeys.ts',
],
specs: ['frontendFotosSelfie'],
},
{
// C234 — the bounded streaming body reader extracted from the beacon: both
// anonymous JSON routes consume it, so a diff there wakes both specs.
prefixes: ['src/utilities/boundedRequestBody.ts'],
specs: ['frontendConteudos', 'frontendFotosSelfie'],
},
{
// S21 — the campaign footer owns the conditional "Jingles" discovery link
@ -457,7 +483,13 @@ export const E2E_AFFECTED_MANIFEST = [
'src/app/(campaign)/campanha/actions/speech.ts',
'src/app/(campaign)/campanha/actions/reels.ts',
],
specs: ['frontend', 'campaignNewsletter', 'campaignSpeechCut', 'campaignReel'],
specs: [
'frontend',
'campaignNewsletter',
'campaignSpeechCut',
'campaignReel',
'frontendFotosSelfie',
],
},
{
// Web Push client — the opt-in toast mounts on the campaign shell, so a
@ -549,7 +581,7 @@ export const E2E_AFFECTED_MANIFEST = [
// The role predicates drive the vertical gate and the assistant surfaces.
'src/lib/campaignRoles',
],
specs: ['campaignSpeechAcervo', 'campaignSpeechCut', 'campaignReel'],
specs: ['campaignSpeechAcervo', 'campaignSpeechCut', 'campaignReel', 'frontendFotosSelfie'],
},
{
// C167 — the unlisted public page of a cut: 200 with the stored file and

View file

@ -0,0 +1,84 @@
/**
* C234 — the Node face engine of the maintenance CLIs: the SAME face-api nets
* the visitor's browser loads (`tinyFaceDetector` + `faceLandmark68Net` +
* `faceRecognitionNet`), with the same options, so a descriptor computed by the
* enrollment/index CLI is comparable with the one computed on the device. The
* wasm backend is the package's own Node pairing (`face-api.node-wasm.js` +
* `@tensorflow/tfjs-backend-wasm`) — no native `tfjs-node` build, no headless
* browser, no network.
*
* The engine only turns prepared pixels into descriptors; the caller owns the
* image preparation (`prepareFaceImage`) and the distance math
* (`src/lib/faceSearch.ts`). It is a manual smoke dependency, never in the
* test suite (the suites inject a stub analyzer).
*/
import { createRequire } from 'node:module'
import path from 'node:path'
const require = createRequire(import.meta.url)
/**
* Loads the nets once per process. Throws a named error when the wasm assets or
* the models are missing — a broken install must fail before any DB write.
*
* @param {string} repoRoot
*/
export const createNodeFaceEngine = async (repoRoot) => {
const { FACE_DETECTOR_INPUT_SIZE, FACE_DETECTOR_SCORE_THRESHOLD } =
await import('../../src/lib/faceSearch.ts')
let faceapi
try {
// The tfjs runtime is required explicitly first: it is the exact instance
// the node-wasm build and the wasm backend register against (same module
// cache), and it keeps the dependency honest for the static checks.
require('@tensorflow/tfjs')
faceapi = require('@vladmandic/face-api/dist/face-api.node-wasm.js')
const { setWasmPaths } = require('@tensorflow/tfjs-backend-wasm')
setWasmPaths(path.join(repoRoot, 'node_modules/@tensorflow/tfjs-backend-wasm/dist/'))
} catch (error) {
throw new Error(
`engine indisponível (instale @vladmandic/face-api e @tensorflow/tfjs-backend-wasm): ${
error instanceof Error ? error.message : error
}`,
)
}
const tf = faceapi.tf
await tf.setBackend('wasm')
await tf.ready()
const modelDir = path.join(repoRoot, 'node_modules/@vladmandic/face-api/model')
await faceapi.nets.tinyFaceDetector.loadFromDisk(modelDir)
await faceapi.nets.faceLandmark68Net.loadFromDisk(modelDir)
await faceapi.nets.faceRecognitionNet.loadFromDisk(modelDir)
const options = new faceapi.TinyFaceDetectorOptions({
inputSize: FACE_DETECTOR_INPUT_SIZE,
scoreThreshold: FACE_DETECTOR_SCORE_THRESHOLD,
})
return {
/**
* Descriptors of every detected face, in detection order.
* @param {{ pixels: Uint8Array, width: number, height: number }} image
* @returns {Promise<number[][]>}
*/
detectPixels: async ({ pixels, width, height }) => {
const tensor = tf.tensor3d(
new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength),
[height, width, 3],
'int32',
)
try {
const results = await faceapi
.detectAllFaces(tensor, options)
.withFaceLandmarks()
.withFaceDescriptors()
return results.map((result) => Array.from(result.descriptor))
} finally {
tensor.dispose()
}
},
}
}

View file

@ -0,0 +1,91 @@
/**
* C234 — pure planning/reporting of `pnpm faces:enroll`: the argv parser and
* the human lines + JSON receipt of the enrollment. No I/O of its own — the
* engine, the Consent resolution and the Payload write arrive as parameters,
* so the unit spec drives everything with fakes.
*/
const USAGE =
'uso: `pnpm faces:enroll --label "<nome>" --selfie <arquivo> [--subject <id>] [--apply]`'
export const FACE_ENROLL_DEFAULT_OUT_DIR = 'data/face'
export const faceEnrollReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-')
/**
* `pnpm faces:enroll` argv parser — pure, so the unit spec pins it (the CLI
* owns the exit and the help text). The default mode is the plan/dry-run;
* `--label` and `--selfie` are required in both modes so the plan shows the
* real operation, not an abstract one.
*
* @param {string[]} [argv]
* @returns {{ apply: boolean, label: string | null, selfie: string | null, subject: number | null, out: string, help: boolean }}
*/
export const parseFaceEnrollCliArgs = (argv = process.argv.slice(2)) => {
const options = {
apply: false,
label: null,
selfie: null,
subject: null,
out: FACE_ENROLL_DEFAULT_OUT_DIR,
help: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = () => {
const next = argv[++index]
if (next === undefined || next.startsWith('--')) {
throw new Error(`faltou valor para ${arg} — ${USAGE}.`)
}
return next
}
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--apply') options.apply = true
else if (arg === '--label') options.label = value()
else if (arg === '--selfie') options.selfie = value()
else if (arg === '--subject') options.subject = Number(value())
else if (arg === '--out') options.out = value()
else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`)
}
if (options.help) return options
if (!options.label?.trim()) throw new Error(`--label é obrigatório — ${USAGE}.`)
if (!options.selfie?.trim()) throw new Error(`--selfie é obrigatório — ${USAGE}.`)
if (options.subject !== null && (!Number.isSafeInteger(options.subject) || options.subject < 1)) {
throw new Error('--subject deve ser um id >= 1.')
}
if (options.out.split(/[\\/]/).includes('..')) {
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
}
return options
}
/**
* The receipt + human lines of one enrollment: what was detected (face count —
* never the descriptor), the subject row and the consent snapshot. A refusal
* (0 or >1 faces) is a report with `allowed: false` and its reason.
*
* @param {{ runAt: string, mode: string, target: string, model: string, label: string, subjectId: number | null, subjectAction: 'created' | 'updated' | null, faceCount: number, allowed: boolean, reason?: string | null, consentKey: string, consentHash: string | null, durationMs: number }} report
*/
export const formatFaceEnrollReport = (report) => {
const lines = [
`[faces:enroll] modo: ${report.mode} | alvo: ${report.target} | modelo: ${report.model}`,
`[faces:enroll] selfie: ${report.faceCount} rosto(s) detectado(s)`,
]
if (!report.allowed) {
lines.push(`[faces:enroll] RECUSADO: ${report.reason ?? 'selfie fora do contrato'}`)
return lines
}
if (report.subjectAction === null) {
lines.push(`[faces:enroll] plano: ${report.label} (nada gravado; confirme com --apply)`)
return lines
}
lines.push(
`[faces:enroll] ${report.subjectAction === 'created' ? 'criada' : 'atualizada'}: #${report.subjectId} (${report.label})`,
`[faces:enroll] consentimento: ${report.consentKey} · hash ${report.consentHash ?? '(ausente)'}`,
`[faces:enroll] próximo passo: pnpm faces:index --apply (reprocessa as fotos aprovadas)`,
)
return lines
}

View file

@ -0,0 +1,170 @@
/**
* C234 — pure planning/reporting of `pnpm faces:index`: the argv parser, the
* run summary, the `--verify` inventory and the human lines + JSON receipt. No
* I/O of its own — the queue, the subjects and the results arrive as
* parameters, so the unit spec drives everything with fakes.
*/
const USAGE = 'uso: `pnpm faces:index [--apply|--verify] [--limit <n>] [--refresh] [--out <dir>]`'
export const FACE_INDEX_DEFAULT_OUT_DIR = 'data/face'
export const faceIndexReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-')
/**
* `pnpm faces:index` argv parser — pure, so the unit spec pins it (the CLI owns
* the exit and the help text). The default mode is the plan/dry-run.
*
* @param {string[]} [argv]
* @returns {{ apply: boolean, verify: boolean, limit: number | null, refresh: boolean, out: string, help: boolean }}
*/
export const parseFaceIndexCliArgs = (argv = process.argv.slice(2)) => {
const options = {
apply: false,
verify: false,
limit: null,
refresh: false,
out: FACE_INDEX_DEFAULT_OUT_DIR,
help: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = () => {
const next = argv[++index]
if (next === undefined || next.startsWith('--')) {
throw new Error(`faltou valor para ${arg} — ${USAGE}.`)
}
return next
}
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--apply') options.apply = true
else if (arg === '--verify') options.verify = true
else if (arg === '--refresh') options.refresh = true
else if (arg === '--limit') options.limit = Number(value())
else if (arg === '--out') options.out = value()
else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`)
}
if (options.help) return options
if (options.apply && options.verify) {
throw new Error(`--apply e --verify são mutuamente exclusivos — ${USAGE}.`)
}
if (options.limit !== null && (!Number.isSafeInteger(options.limit) || options.limit < 1)) {
throw new Error('--limit deve ser >= 1.')
}
if (options.out.split(/[\\/]/).includes('..')) {
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
}
return options
}
/**
* Aggregates one apply run: photos indexed (and how many subjects each linked),
* photos refused by the engine/IO with stage+reason.
*
* @param {Array<{ photoId: number, status: string, matchedSubjects?: number[], stage?: string | null, error?: string | null }>} results
*/
export const summarizeFaceIndexResults = (results) => {
const linkedBySubject = new Map()
const failures = []
let indexed = 0
let failed = 0
let linkedPhotos = 0
for (const result of results) {
if (result.status === 'indexed') {
indexed += 1
const matched = result.matchedSubjects ?? []
if (matched.length > 0) linkedPhotos += 1
for (const id of matched) linkedBySubject.set(id, (linkedBySubject.get(id) ?? 0) + 1)
} else if (result.status === 'failed') {
failed += 1
failures.push({
photoId: result.photoId,
stage: result.stage ?? null,
error: result.error ?? null,
})
}
}
return {
indexed,
failed,
linkedPhotos,
linksBySubject: [...linkedBySubject.entries()]
.sort((a, b) => a[0] - b[0])
.map(([subjectId, photos]) => ({ subjectId, photos })),
failures,
}
}
/**
* The `--verify` inventory: the honest gap is `stale` — approved photos whose
* marker is not the current index revision (new enrollment, new model or never
* indexed). Consent-edited subjects are reported separately: the query already
* fails closed on them, but the operator must see they need re-consent.
*
* @param {{ queue: { indexKey: string, eligibleSubjects: number, totalApproved: number, stale: number }, subjects: Array<{ id: number, status: string | null, model: string | null, consentHash: string | null, matchedPhotoIds: number[] }>, currentConsentHash: string | null }} input
*/
export const summarizeFaceIndexInventory = ({ queue, subjects, currentConsentHash }) => {
const active = subjects.filter((subject) => subject.status === 'active')
const staleConsent = active.filter((subject) => subject.consentHash !== currentConsentHash)
const linksBySubject = subjects
.filter((subject) => subject.matchedPhotoIds.length > 0)
.map((subject) => ({ subjectId: subject.id, photos: subject.matchedPhotoIds.length }))
.sort((a, b) => a.subjectId - b.subjectId)
return {
indexKey: queue.indexKey,
totalApproved: queue.totalApproved,
indexed: Math.max(0, queue.totalApproved - queue.stale),
stale: queue.stale,
// The eligibility predicate belongs to the batch owner; the inventory just
// reports its count (a corrupted vector never counts as eligible).
eligibleSubjects: queue.eligibleSubjects,
activeSubjects: active.length,
removedSubjects: subjects.length - active.length,
staleConsentSubjects: staleConsent.length,
linkedPhotos: linksBySubject.reduce((sum, link) => sum + link.photos, 0),
linksBySubject,
}
}
/** One line per counted state of the inventory (pt-BR, operator-facing). */
export const formatFaceIndexInventory = (inventory, model) => [
`[faces:index] modelo: ${model}`,
`[faces:index] fotos aprovadas: ${inventory.totalApproved}`,
`[faces:index] indexadas na revisão atual: ${inventory.indexed}`,
`[faces:index] desatualizadas (a processar): ${inventory.stale}`,
`[faces:index] sujeitos: ${inventory.activeSubjects} ativo(s), ${inventory.removedSubjects} removido(s), ${inventory.eligibleSubjects} elegível(is)`,
`[faces:index] sujeitos com consentimento vencido (inelegíveis até re-consentir): ${inventory.staleConsentSubjects}`,
`[faces:index] vínculos foto↔sujeito: ${inventory.linkedPhotos}`,
...inventory.linksBySubject.map(
(link) => `[faces:index] sujeito #${link.subjectId}: ${link.photos} foto(s)`,
),
]
/** The receipt + human lines of one plan/apply run. */
export const formatFaceIndexReport = (report) => {
const lines = [
`[faces:index] modo: ${report.mode} | alvo: ${report.target} | modelo: ${report.model}`,
`[faces:index] fila: ${report.queue.items} foto(s) a processar (aprovadas: ${report.queue.totalApproved} | sujeitos elegíveis: ${report.queue.eligibleSubjects})`,
]
if (report.mode !== 'plan') {
lines.push(
`[faces:index] processadas: ${report.summary.indexed} | vinculadas a alguém: ${report.summary.linkedPhotos} | falharam: ${report.summary.failed}`,
)
for (const link of report.summary.linksBySubject) {
lines.push(`[faces:index] sujeito #${link.subjectId}: ${link.photos} foto(s)`)
}
}
for (const failure of report.summary?.failures ?? []) {
lines.push(`[faces:index] ✗ foto ${failure.photoId} [${failure.stage}]: ${failure.error}`)
}
return lines
}

View file

@ -76,6 +76,8 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/dossieResearch.mjs',
'scripts/lib/dossieUnit.mjs',
'scripts/lib/e2e-affected-manifest.mjs',
'scripts/lib/faceEnrollPlan.mjs',
'scripts/lib/faceIndexPlan.mjs',
'scripts/lib/flickrApi.mjs',
'scripts/lib/flickrPlan.mjs',
'scripts/lib/github-api.mjs',

View file

@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { getCardModel } from '@/lib/cardModels'
import { contentEventRequestSchema } from '@/lib/schemas/contentEvent'
import { getStateDeputyCard } from '@/lib/stateDeputyCatalog'
import { readBoundedRequestBody } from '@/utilities/boundedRequestBody'
import {
checkContentEventRateLimit,
contentEventClientKey,
@ -37,43 +38,10 @@ const MAX_BODY_BYTES = 4 * 1024
const silentResponse = (status: number): NextResponse =>
new NextResponse(null, { status, headers: NO_STORE })
/**
* Reads the body with a hard byte ceiling, streaming and aborting as soon as
* the ceiling is crossed — a chunked body without `content-length` cannot make
* the route buffer unbounded input. `null` means "refused".
*/
const readBoundedBody = async (request: Request): Promise<string | null> => {
const body = request.body
if (!body) return ''
const reader = body.getReader()
const decoder = new TextDecoder()
let text = ''
let bytes = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
bytes += value.byteLength
if (bytes > MAX_BODY_BYTES) {
await reader.cancel().catch(() => undefined)
return null
}
text += decoder.decode(value, { stream: true })
}
return text + decoder.decode()
} catch {
await reader.cancel().catch(() => undefined)
return null
}
}
export const POST = async (request: Request): Promise<NextResponse> => {
if (!isSameOriginRequest(request)) return silentResponse(403)
const rawBody = await readBoundedBody(request)
const rawBody = await readBoundedRequestBody(request, MAX_BODY_BYTES)
if (rawBody === null) return silentResponse(400)
let body: unknown

View file

@ -0,0 +1,130 @@
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { FACE_INDEX_CONSENT_KEY, FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import {
FACE_SEARCH_MODEL,
FACE_SEARCH_RESULT_LIMIT,
findFaceMatch,
toFaceSearchPhotoView,
} from '@/lib/faceSearch'
import { faceSearchRequestSchema } from '@/lib/schemas/faceSearch'
import { getApprovedArchivePhotoItems } from '@/utilities/archivePhotos/archivePhotoReads'
import { readBoundedRequestBody } from '@/utilities/boundedRequestBody'
import { getConsentByKey } from '@/utilities/campaignConsent'
import {
checkContentEventRateLimit,
contentEventClientKey,
} from '@/utilities/content/contentEventRateLimit'
import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment'
import {
getFaceSubjectMatchedPhotoIds,
listSearchableFaceSubjects,
} from '@/utilities/faceSubjects/faceSubjectReads'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
/**
* C234 — the anonymous endpoint of the selfie search. The browser computes the
* face descriptor ON DEVICE (the selfie image never reaches this server) and
* sends only the 128-float vector plus the intent; the answer is a list of
* approved photos, never a score and never a third-party name.
*
* Fail-closed in layers: same-origin, bounded body, strict schema, a budget
* much stricter than the beacon's (the endpoint answers a membership oracle),
* the album's `selfieSearchEnabled` kill switch AND the query Consent resolved
* server-side — the page closing is convenience, this is the gate. Scope A/C
* of the gate (PR #1370): only subjects enrolled with consent answer; the
* anonymous archive index (B) does not exist. `leave-index` is authorized by
* the matched descriptor itself.
*/
const NO_STORE = { 'Cache-Control': 'no-store' } as const
/** The descriptor JSON is ~2 KB; anything past 4 KB is not ours. */
const MAX_BODY_BYTES = 4 * 1024
/** Requests per 10-minute window per hashed IP — strict on purpose. */
const FACE_SEARCH_REQUESTS_PER_WINDOW = 20
const jsonResponse = (body: unknown, status = 200): NextResponse =>
NextResponse.json(body, { status, headers: NO_STORE })
export const POST = async (request: Request): Promise<NextResponse> => {
if (!isSameOriginRequest(request)) return jsonResponse({ ok: false, error: 'origin' }, 403)
const rawBody = await readBoundedRequestBody(request, MAX_BODY_BYTES)
if (rawBody === null) return jsonResponse({ ok: false, error: 'body' }, 400)
let body: unknown
try {
body = JSON.parse(rawBody)
} catch {
return jsonResponse({ ok: false, error: 'body' }, 400)
}
const parsed = faceSearchRequestSchema.safeParse(body)
if (!parsed.success) return jsonResponse({ ok: false, error: 'body' }, 400)
// The registry/hash owner is shared with the beacon, but the budget window is
// NOT: the caller namespaces its key so beacon events and selfie searches
// never consume each other's counters.
const clientKey = contentEventClientKey(request.headers)
if (
!checkContentEventRateLimit(clientKey && `selfie:${clientKey}`, FACE_SEARCH_REQUESTS_PER_WINDOW)
) {
return jsonResponse({ ok: false, error: 'rate-limited' }, 429)
}
const payload = await getPayload({ config })
// Direct read (no cache): the kill switch and the consent must be the state
// of this moment — the endpoint is the gate, the page is convenience. The
// album global's read access is public by contract, so no bypass is needed.
const [album, searchConsent] = await Promise.all([
payload.findGlobal({ slug: 'photoAlbum', depth: 0 }).catch(() => null),
getConsentByKey(payload, FACE_SEARCH_CONSENT_KEY),
])
if (album?.published === false || album?.selfieSearchEnabled !== true) {
return jsonResponse({ ok: false, error: 'closed' }, 404)
}
if (!searchConsent) return jsonResponse({ ok: false, error: 'consent' }, 503)
const [subjects, indexConsent] = await Promise.all([
listSearchableFaceSubjects(payload),
getConsentByKey(payload, FACE_INDEX_CONSENT_KEY),
])
const match = indexConsent
? findFaceMatch({
vector: parsed.data.vector,
subjects,
model: FACE_SEARCH_MODEL,
consentHash: indexConsent.contentHash,
})
: null
if (parsed.data.intent === 'leave-index') {
if (!match) return jsonResponse({ ok: true, removed: false })
await removeFaceSubjectFromIndex({ payload, id: match.id })
return jsonResponse({ ok: true, removed: true })
}
if (!match) return jsonResponse({ ok: true, found: false })
const [matchedIds, approved] = await Promise.all([
getFaceSubjectMatchedPhotoIds(payload, match.id),
getApprovedArchivePhotoItems(),
])
const linked = new Set(matchedIds)
const photos = approved
.filter((item) => linked.has(item.id))
.slice(0, FACE_SEARCH_RESULT_LIMIT)
.map(toFaceSearchPhotoView)
if (photos.length === 0) return jsonResponse({ ok: true, found: false })
return jsonResponse({ ok: true, found: true, photos })
}

View file

@ -0,0 +1,114 @@
import config from '@payload-config'
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { ConsentText } from '@/components/campaign/shared/ConsentText'
import { ARCHIVE_PHOTO_FOCUS } from '@/components/fotos/archivePhotoClasses'
import { ArchivePhotoPageHeader } from '@/components/fotos/ArchivePhotoPageHeader'
import { SelfieSearchClosed } from '@/components/fotos/SelfieSearchClosed'
import { SelfieSearchFlow } from '@/components/fotos/SelfieSearchFlow'
import { isArchivePhotoRemovalChannelUrl } from '@/lib/archivePhotoCatalog'
import {
ARCHIVE_PHOTO_ALBUM_ENTRY_PATH,
ARCHIVE_PHOTO_ALBUM_PATH,
} from '@/lib/archivePhotoPublicCatalog'
import { FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import { getConsentByKey } from '@/utilities/campaignConsent'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
export const dynamic = 'force-dynamic'
const ENTRY_TITLE = 'Encontre você nas fotos — Jorge Solla 1313'
const ENTRY_DESCRIPTION =
'Busque por uma selfie sua as fotos públicas aprovadas em que você aparece. A imagem não sai do seu dispositivo.'
const getCachedPhotoAlbum = getCachedGlobal('photoAlbum')
/**
* C234 — the selfie search page (`/fotos/encontre`), a surface of the album:
* the same album kill switch (`photoAlbum.published`) and the feature flag
* (`selfieSearchEnabled`, default off) gate the route with the album's 404; the
* query Consent resolved by stable key is the legal gate — without it the page
* still exists but renders only the fail-closed state, and the API refuses
* independently. Noindex on purpose: the canonical surface is the album.
*/
export async function generateMetadata(): Promise<Metadata> {
const [album, globalMetadata] = await Promise.all([
getCachedPhotoAlbum(),
getCachedGlobal('metadata')(),
])
if (album.published === false || album.selfieSearchEnabled !== true) notFound()
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
const canonicalUrl = absoluteSitePath(siteUrl, ARCHIVE_PHOTO_ALBUM_ENTRY_PATH)
const title = `${ENTRY_TITLE} | ${siteName}`
return {
title,
description: ENTRY_DESCRIPTION,
robots: { index: false, follow: false },
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'website',
locale: 'pt-BR',
...(canonicalUrl ? { url: canonicalUrl } : {}),
siteName,
title,
description: ENTRY_DESCRIPTION,
images: [],
},
twitter: {
card: 'summary',
title,
description: ENTRY_DESCRIPTION,
creator: twitterCreator,
images: [],
},
}
}
export default async function FotosEncontrePage() {
const [album, showConteudos] = await Promise.all([
getCachedPhotoAlbum(),
hasPublishedContentPieces(),
])
if (album.published === false || album.selfieSearchEnabled !== true) notFound()
const payload = await getPayload({ config })
const consent = await getConsentByKey(payload, FACE_SEARCH_CONSENT_KEY)
const removalChannelUrl = isArchivePhotoRemovalChannelUrl(album.removalChannelUrl)
? album.removalChannelUrl.trim()
: null
return (
<>
<ArchivePhotoPageHeader showConteudos={showConteudos} />
<main className="w-full">
<div className="mx-auto w-full max-w-6xl px-5 pt-5 sm:px-8 sm:pt-7">
<nav className="text-sm text-(--campaign-muted)">
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`inline-flex min-h-11 items-center font-bold text-[#184e92] underline underline-offset-4 hover:text-[#143c70] ${ARCHIVE_PHOTO_FOCUS}`}
>
← Voltar ao álbum
</Link>
</nav>
</div>
{consent ? (
<SelfieSearchFlow
consentText={<ConsentText data={consent.text} />}
removalChannelUrl={removalChannelUrl}
/>
) : (
<div className="px-5 py-8 sm:px-8 sm:py-10">
<SelfieSearchClosed />
</div>
)}
</main>
</>
)
}

View file

@ -9,6 +9,7 @@ import {
ArchivePhotoEmptyState,
ArchivePhotoNoResults,
} from '@/components/fotos/ArchivePhotoStates'
import { SelfieSearchEntry } from '@/components/fotos/SelfieSearchEntry'
import { isArchivePhotoRemovalChannelUrl } from '@/lib/archivePhotoCatalog'
import {
ARCHIVE_PHOTO_ALBUM_PATH,
@ -126,7 +127,17 @@ export default async function FotosPage({
<ArchivePhotoEmptyState />
) : (
<>
<ArchivePhotoFilters params={params} facets={facets} activeFilters={activeFilters} />
{album.selfieSearchEnabled === true ? (
<div className="mb-8">
<SelfieSearchEntry />
</div>
) : null}
<ArchivePhotoFilters
params={params}
facets={facets}
activeFilters={activeFilters}
searchSecondary={album.selfieSearchEnabled === true}
/>
<div className="mt-10 flex items-end justify-between gap-4 border-b border-(--campaign-line) pb-4">
<div>

View file

@ -165,6 +165,12 @@ const deriveArchivePhotoCatalogIndex: CollectionBeforeValidateHook = async ({
}) => {
if (!data) return data
// C234 — the face-index batch only ever writes the hidden `faces` group
// (the marker), so it skips the search-text/município derivation entirely;
// the curation marking below is already gated on `req.user` and never runs
// for a session-less CLI.
if (req.context?.faceIndex) return data
if (req.user && !req.context?.archivePhotoCatalog) {
const changed = changedArchivePhotoCuratedFields({ data, originalDoc })
if (changed.length > 0) {
@ -276,6 +282,41 @@ const revalidateArchivePhotosListingAfterDelete: CollectionAfterDeleteHook = ({
return doc
}
/**
* C234 — the batch state of the selfie-search index (`pnpm faces:index`). The
* `checkedKey` is what lets the batch skip a photo honestly: it summarizes the
* model plus every eligible subject, so a new enrollment (or a model change)
* changes the key and the photo is reprocessed. Written only by the CLI; no
* admin surface (hidden) and never edited by hand.
*/
const facesGroup: Field = {
name: 'faces',
type: 'group',
label: 'Índice facial',
admin: {
hidden: true,
description: 'Estado do lote da busca por selfie (C234). Nunca editado à mão.',
},
fields: [
{
name: 'checkedAt',
type: 'date',
label: 'Verificada em',
admin: {
readOnly: true,
},
},
{
name: 'checkedKey',
type: 'text',
label: 'Chave do índice',
admin: {
readOnly: true,
},
},
],
}
export const ArchivePhoto: CollectionConfig = {
slug: ARCHIVE_PHOTO_SLUG,
labels: {
@ -502,6 +543,7 @@ export const ArchivePhoto: CollectionConfig = {
'Tudo o que a busca da lista encontra, normalizado (sem acentos, minúsculas): legenda, texto visível, temas, pessoas, município, álbuns e tags.',
},
},
facesGroup,
],
upload: true,
}

View file

@ -22,6 +22,7 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({
leaderships,
supporters,
voteSupporters,
faceSubjects,
] = await Promise.all([
req.payload.count({
collection: 'subscription',
@ -65,6 +66,14 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({
overrideAccess: true,
req,
}),
// C234 — face-subject references: intentional access bypass like every
// count above (the hook must see every reference to decide).
req.payload.count({
collection: 'faceSubject',
where: { consent: { equals: id } },
overrideAccess: true,
req,
}),
])
const references: Array<[number, string]> = [
@ -75,6 +84,7 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({
[leaderships.totalDocs, 'lideranças'],
[supporters.totalDocs, 'apoiadores'],
[voteSupporters.totalDocs, 'apoiadores (intenção de voto)'],
[faceSubjects.totalDocs, 'pessoas no índice de busca por selfie'],
]
const used = references.filter(([count]) => count > 0)
if (used.length === 0) return

View file

@ -0,0 +1,135 @@
import type { CollectionConfig } from 'payload'
import { payloadAdminOnly } from '@/utilities/campaignAccess'
/**
* C234 — one person who ADHERED to the selfie search index (A/C scope of the
* gate, PR #1370): the enrollment is an ops act of the assessoria with the
* person (signed consent, `pnpm faces:enroll`), never self-service and never
* inferred from the archive.
*
* This is not a people registry: it has no link to `Contact`/leadership, no
* public read and no surface of its own — it exists to (a) hold the consented
* descriptor that lets the person find themselves and (b) carry the audit
* trail of the adherence (`consent` + `consentHash` + `model` + status). The
* descriptor is written only by the CLI and never leaves the server; the
* matched photos are a derived link the batch replaces on every run. A
* `removed` subject keeps its row (the attendance record) but not the vector.
*/
export const FaceSubject: CollectionConfig = {
slug: 'faceSubject',
labels: {
singular: 'Pessoa no índice facial',
plural: 'Pessoas no índice facial',
},
admin: {
group: 'Comunicação',
description:
'Pessoas que consentiram entrar no índice da busca por selfie (/fotos/encontre). Operado pela CLI faces:enroll/faces:index; a remoção é registrada no status.',
useAsTitle: 'label',
defaultColumns: ['label', 'status', 'model', 'removedAt'],
},
access: {
create: payloadAdminOnly,
read: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
fields: [
{
name: 'label',
type: 'text',
label: 'Nome (interno)',
required: true,
admin: {
description: 'Como a assessoria identifica a pessoa; nunca aparece no site.',
},
},
{
name: 'consent',
type: 'relationship',
relationTo: 'consent',
label: 'Consentimento (adesão)',
required: true,
index: true,
admin: {
description: 'O Consent da adesão ao índice (chave estável busca-selfie-indice).',
},
},
{
name: 'consentHash',
type: 'text',
label: 'Hash do consentimento',
admin: {
readOnly: true,
description:
'Snapshot do texto aceito no enrollment; se o texto mudar, a pessoa fica inelegível até re-consentir.',
},
},
{
name: 'model',
type: 'text',
label: 'Modelo',
admin: {
readOnly: true,
description: 'Modelo do descriptor (troca de modelo invalida o índice: re-enrollment).',
},
},
{
name: 'enrolledAt',
type: 'date',
label: 'Inscrita em',
admin: {
readOnly: true,
description:
'Muda a cada enrollment/re-enrollment; é o que marca o lote como stale (o updated_at muda também quando o lote escreve os vínculos).',
},
},
{
name: 'vector',
type: 'json',
label: 'Descriptor',
admin: {
hidden: true,
readOnly: true,
},
},
{
name: 'status',
type: 'select',
label: 'Status',
required: true,
index: true,
defaultValue: 'active',
options: [
{ label: 'Ativa', value: 'active' },
{ label: 'Removida do índice', value: 'removed' },
],
admin: {
description:
'Removida: pedido de saída atendido — o vetor é apagado e a pessoa deixa de ser encontrada.',
},
},
{
name: 'removedAt',
type: 'date',
label: 'Removida em',
admin: {
readOnly: true,
description: 'Data do atendimento da saída do índice.',
},
},
{
name: 'matchedPhotos',
type: 'relationship',
relationTo: 'archivePhoto',
hasMany: true,
label: 'Fotos vinculadas',
admin: {
readOnly: true,
description:
'Derivado do lote (pnpm faces:index): fotos aprovadas em que o descriptor foi encontrado.',
},
},
],
}

View file

@ -9,6 +9,49 @@ import {
import { ARCHIVE_PHOTO_CARD } from './archivePhotoClasses'
/**
* C233 — the card body every photo grid shares (album and, without the
* third-party line, the C234 selfie-search result): image through the
* same-origin private proxy, curated title, meta line and the optional
* "Quem aparece". The caller owns the link/href contract.
*/
export const ArchivePhotoCardBody = ({
item,
}: {
item: Pick<
ArchivePhotoPublicItem,
'alt' | 'title' | 'metaLabel' | 'peopleLabel' | 'thumbnailPath'
>
}) => (
<>
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
<img
src={item.thumbnailPath}
alt={item.alt}
loading="lazy"
decoding="async"
// The CSS box owns the ratio (square on mobile, 4:3 from `sm`); the
// attributes are the intrinsic hint for the pre-JS layout.
width={ARCHIVE_PHOTO_THUMB_WIDTH}
height={Math.round(ARCHIVE_PHOTO_THUMB_WIDTH * 0.75)}
className="aspect-square w-full rounded-lg object-cover transition-[transform,box-shadow] duration-150 ease-out group-hover:-translate-y-0.5 group-hover:shadow-[0_12px_28px_rgb(32_27_26/16%)] motion-reduce:transition-none sm:aspect-[4/3] sm:rounded-xl"
/>
<div className="pt-3">
<p className="font-[family-name:var(--font-exo2)] text-sm font-black text-balance sm:text-base">
{item.title}
</p>
{item.metaLabel ? (
<p className="mt-1 text-xs leading-4 text-(--campaign-muted) sm:text-sm sm:leading-5">
{item.metaLabel}
</p>
) : null}
{item.peopleLabel ? (
<p className="mt-1 text-xs leading-4 sm:text-sm sm:leading-5">{item.peopleLabel}</p>
) : null}
</div>
</>
)
/**
* C233 — one photo of the grid (artefato: cenas 01/02/07): the whole card is a
* single link to the same filtered URL plus `?foto=<id>`, one tab stop, yellow
@ -29,31 +72,7 @@ export const ArchivePhotoCard = ({
className={`${ARCHIVE_PHOTO_CARD} group`}
data-photo-id={item.id}
>
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
<img
src={item.thumbnailPath}
alt={item.alt}
loading="lazy"
decoding="async"
// The CSS box owns the ratio (square on mobile, 4:3 from `sm`); the
// attributes are the intrinsic hint for the pre-JS layout.
width={ARCHIVE_PHOTO_THUMB_WIDTH}
height={Math.round(ARCHIVE_PHOTO_THUMB_WIDTH * 0.75)}
className="aspect-square w-full rounded-lg object-cover transition-[transform,box-shadow] duration-150 ease-out group-hover:-translate-y-0.5 group-hover:shadow-[0_12px_28px_rgb(32_27_26/16%)] motion-reduce:transition-none sm:aspect-[4/3] sm:rounded-xl"
/>
<div className="pt-3">
<p className="font-[family-name:var(--font-exo2)] text-sm font-black text-balance sm:text-base">
{item.title}
</p>
{item.metaLabel ? (
<p className="mt-1 text-xs leading-4 text-(--campaign-muted) sm:text-sm sm:leading-5">
{item.metaLabel}
</p>
) : null}
{item.peopleLabel ? (
<p className="mt-1 text-xs leading-4 sm:text-sm sm:leading-5">{item.peopleLabel}</p>
) : null}
</div>
<ArchivePhotoCardBody item={item} />
</Link>
</article>
)

View file

@ -18,6 +18,7 @@ import {
ARCHIVE_PHOTO_CHIP,
ARCHIVE_PHOTO_FOCUS,
ARCHIVE_PHOTO_PRIMARY_BUTTON,
ARCHIVE_PHOTO_SECONDARY_BUTTON,
} from './archivePhotoClasses'
const FACET_MENU_HEADINGS: Record<ArchivePhotoAlbumFacet, string> = {
@ -75,10 +76,13 @@ export const ArchivePhotoFilters = ({
params,
facets,
activeFilters,
searchSecondary = false,
}: {
params: ArchivePhotoAlbumParams
facets: ArchivePhotoAlbumFacets
activeFilters: readonly ArchivePhotoAlbumActiveFilter[]
/** C234 — with the selfie entry band above, the album search is the secondary action. */
searchSecondary?: boolean
}) => {
const facetFilters = activeFilters.filter((filter): filter is FacetFilter => filter.facet !== 'q')
const termFilter = activeFilters.find((filter) => filter.facet === 'q')
@ -123,7 +127,10 @@ export const ArchivePhotoFilters = ({
</Link>
) : null}
</div>
<button type="submit" className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} min-w-28`}>
<button
type="submit"
className={`${searchSecondary ? ARCHIVE_PHOTO_SECONDARY_BUTTON : ARCHIVE_PHOTO_PRIMARY_BUTTON} min-w-28`}
>
Buscar fotos
</button>
</div>

View file

@ -0,0 +1,36 @@
import { TriangleAlert } from 'lucide-react'
import Link from 'next/link'
import { ARCHIVE_PHOTO_ALBUM_PATH } from '@/lib/archivePhotoPublicCatalog'
import { ARCHIVE_PHOTO_SECONDARY_BUTTON } from './archivePhotoClasses'
/**
* C234 — the fail-closed state of the selfie search (artefato: cena 07): the
* query Consent is not configured, so the flow never opens and no selfie can be
* captured or processed. Server-rendered on `/fotos/encontre` and reused by the
* client flow when the flag/consent flips mid-session.
*/
export const SelfieSearchClosed = () => (
<section className="mx-auto w-full max-w-[920px] rounded-2xl border border-amber-300 bg-amber-50 px-6 py-10 text-center">
<div className="mx-auto grid size-12 place-items-center rounded-full border border-amber-300 bg-white text-amber-800">
<TriangleAlert aria-hidden="true" className="size-6" strokeWidth={2} />
</div>
<p className="mt-5 text-xs font-black tracking-[0.1em] text-amber-800 uppercase">
Proteção de dados
</p>
<h2 className="mt-2 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black text-amber-950">
A busca por selfie está indisponível
</h2>
<p className="mx-auto mt-3 max-w-lg text-sm leading-6 text-amber-900">
O texto de consentimento necessário ainda não está configurado. Por segurança, não podemos
receber nem processar sua selfie agora.
</p>
<p className="mx-auto mt-3 max-w-lg text-sm font-bold text-amber-950">
Nenhuma imagem foi capturada ou enviada.
</p>
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} mt-6`}>
Voltar ao álbum de fotos
</Link>
</section>
)

View file

@ -0,0 +1,44 @@
import { ChevronRight, Sparkles } from 'lucide-react'
import Link from 'next/link'
import { ARCHIVE_PHOTO_ALBUM_ENTRY_PATH } from '@/lib/archivePhotoPublicCatalog'
import { ARCHIVE_PHOTO_PRIMARY_BUTTON } from './archivePhotoClasses'
/**
* C234 — the entry band of the selfie search on `/fotos` (artefato: cena 01,
* revisada): a full-width band between the hero and the facets, never a second
* column in the hero. Rendered only while the feature flag is on and the album
* has approved photos to find; the visual contract is the artifact's
* `rounded-2xl border border-[#184e92]/15 bg-[#f9faff]` band.
*/
export const SelfieSearchEntry = () => (
<aside className="grid grid-cols-1 items-center gap-4 rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-5 sm:grid-cols-[1fr_auto] sm:gap-6 sm:px-6">
<div className="flex items-start gap-4">
<div className="grid size-11 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white">
<Sparkles aria-hidden="true" className="size-5" strokeWidth={2} />
</div>
<div>
<h2 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-lg font-black sm:text-xl">
Encontre você nas fotos
</h2>
<p className="mt-1 text-sm leading-6 text-(--campaign-muted)">
<span className="sm:hidden">
Sua selfie fica neste aparelho. A busca é para quem aderiu ao índice.
</span>
<span className="hidden sm:inline">
Use uma selfie sua. A imagem não sai deste dispositivo e a busca cobre quem autorizou
participar do índice.
</span>
</p>
</div>
</div>
<Link
href={ARCHIVE_PHOTO_ALBUM_ENTRY_PATH}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full shrink-0 sm:w-auto`}
>
Começar busca por selfie
<ChevronRight aria-hidden="true" className="size-4" />
</Link>
</aside>
)

View file

@ -0,0 +1,886 @@
'use client'
import {
Camera,
ChevronLeft,
Clock,
ImageIcon,
RefreshCw,
ShieldCheck,
Smartphone,
Sparkles,
Trash2,
TriangleAlert,
Upload,
WifiOff,
} from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { ARCHIVE_PHOTO_ALBUM_PATH } from '@/lib/archivePhotoPublicCatalog'
import type { FaceSearchPhotoView } from '@/lib/faceSearch'
import {
ARCHIVE_PHOTO_FOCUS,
ARCHIVE_PHOTO_PRIMARY_BUTTON,
ARCHIVE_PHOTO_SECONDARY_BUTTON,
} from './archivePhotoClasses'
import { computeSelfieDescriptor, supportsFaceSearchEngine } from './faceSearchEngine'
import { SelfieSearchClosed } from './SelfieSearchClosed'
import { SelfieSearchResultCard } from './SelfieSearchResultCard'
type SelfieSearchIntent = 'search' | 'leave-index'
type FlowStep =
| { kind: 'consent' }
| { kind: 'pick'; intent: SelfieSearchIntent }
| { kind: 'confirm-leave' }
| { kind: 'processing'; intent: SelfieSearchIntent }
| { kind: 'results'; photos: FaceSearchPhotoView[] }
| { kind: 'empty' }
| { kind: 'no-face' }
| { kind: 'presence' }
| { kind: 'removed' }
| { kind: 'presence-missing' }
| { kind: 'unsupported' }
| { kind: 'engine-error' }
| { kind: 'rate-limited' }
| { kind: 'network' }
| { kind: 'closed' }
type SelfieSearchResponse =
| { ok: true; found: true; photos: FaceSearchPhotoView[] }
| { ok: true; found: false }
| { ok: true; removed: boolean }
| { ok: false; error: string }
const ACCEPTED_IMAGE = 'image/*'
const FOCUSABLE = 'a[href]:not([tabindex="-1"]), button:not([disabled]), input:not([disabled])'
/** The wizard steps center on a readable column; the result breaks out to the page grid. */
const STEP_SHELL = 'mx-auto w-full max-w-[650px]'
/**
* C234 — the selfie search flow (artefato: cenas 02–13). The browser computes
* the descriptor on device and posts only the 128-float vector; the component
* never puts the image in a request. Every terminal state of the artifact has
* an exact counterpart here (empty, no-face, engine/unsupported, rate limit,
* network, closed) and none of them shows a score, a percentage or a
* third-party name. The consent text arrives rendered from the server (the
* approved Consent reachText); the opt-out is authorized by the same face
* match, and its no-match case points to the album's configured channel.
*/
export const SelfieSearchFlow = ({
consentText,
removalChannelUrl,
}: {
consentText: ReactNode
removalChannelUrl: string | null
}) => {
const [step, setStep] = useState<FlowStep>({ kind: 'consent' })
const [consented, setConsented] = useState(false)
const [activeIntent, setActiveIntent] = useState<SelfieSearchIntent>('search')
const [file, setFile] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const previewRef = useRef<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const resultsRef = useRef<HTMLElement>(null)
const presenceRef = useRef<HTMLElement>(null)
const pickRef = useRef<HTMLElement>(null)
const processingRef = useRef<HTMLElement>(null)
useEffect(
() => () => {
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
},
[],
)
// The async transitions unmount the control that had focus; every new step
// starts at its own top (the album scrolls inside its segment container, so
// `scrollIntoView` is what reaches it) with focus on the heading region —
// keyboard/screen-reader users never land mid-step (the terminal cards focus
// and scroll themselves).
useEffect(() => {
const target =
step.kind === 'pick'
? pickRef.current
: step.kind === 'processing'
? processingRef.current
: step.kind === 'results'
? resultsRef.current
: step.kind === 'presence'
? presenceRef.current
: null
target?.scrollIntoView({ block: 'start' })
target?.focus()
}, [step.kind])
const releasePreview = () => {
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
previewRef.current = null
setPreviewUrl(null)
}
const selectFile = (next: File | null) => {
releasePreview()
setFile(next)
if (next) {
previewRef.current = URL.createObjectURL(next)
setPreviewUrl(previewRef.current)
}
}
const runSearch = async (intent: SelfieSearchIntent) => {
if (!file) return
setStep({ kind: 'processing', intent })
const computed = await computeSelfieDescriptor(file)
if (!computed.ok) {
setStep(
computed.reason === 'unsupported'
? { kind: 'unsupported' }
: computed.reason === 'empty'
? { kind: 'no-face' }
: { kind: 'engine-error' },
)
return
}
try {
const response = await fetch('/api/fotos/selfie', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ vector: computed.descriptor, intent }),
})
const body = (await response.json().catch(() => null)) as SelfieSearchResponse | null
if (response.status === 429) return setStep({ kind: 'rate-limited' })
if (response.status === 404 || response.status === 503) return setStep({ kind: 'closed' })
if (!response.ok || body === null || !body.ok) return setStep({ kind: 'engine-error' })
if (intent === 'leave-index') {
if (!('removed' in body)) return setStep({ kind: 'engine-error' })
return setStep(body.removed ? { kind: 'removed' } : { kind: 'presence-missing' })
}
if ('found' in body && body.found) return setStep({ kind: 'results', photos: body.photos })
return setStep({ kind: 'empty' })
} catch {
setStep({ kind: 'network' })
}
}
const startSelfieStep = (intent: SelfieSearchIntent) => {
if (!supportsFaceSearchEngine()) {
setStep({ kind: 'unsupported' })
return
}
setActiveIntent(intent)
selectFile(null)
setStep({ kind: 'pick', intent })
}
const openPicker = () => inputRef.current?.click()
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const next = event.target.files?.[0] ?? null
event.target.value = ''
selectFile(next)
}
const reset = () => {
selectFile(null)
setConsented(false)
setStep({ kind: 'consent' })
}
return (
<div className="w-full px-5 py-8 sm:px-8 sm:py-10">
<input
ref={inputRef}
type="file"
accept={ACCEPTED_IMAGE}
className="sr-only"
aria-label="Escolher uma selfie"
onChange={onFileChange}
/>
{step.kind === 'consent' ? (
<p className="mb-4 text-center text-sm font-bold text-(--campaign-muted)">
Encontre você nas fotos
</p>
) : null}
{step.kind === 'consent' ? (
<section
aria-labelledby="selfie-consent-title"
className={`${STEP_SHELL} rounded-2xl border border-(--campaign-line) bg-white p-6 sm:p-7`}
>
<div className="flex items-start justify-between gap-4">
<div className="grid size-12 place-items-center rounded-xl bg-[#eef4fb] text-[#184e92]">
<ShieldCheck aria-hidden="true" className="size-6" strokeWidth={2} />
</div>
<span className="rounded-full bg-[#eef4fb] px-3 py-1 text-xs font-extrabold text-[#184e92]">
Passo 1 de 3
</span>
</div>
<p className="mt-5 text-xs font-black tracking-[0.1em] text-[#184e92] uppercase">
Antes da sua selfie
</p>
<h2
id="selfie-consent-title"
className="mt-2 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black"
>
Sua face é um dado biométrico sensível
</h2>
<div className="mt-3 max-h-72 overflow-y-auto rounded-xl border border-(--campaign-line) bg-(--campaign-cream) p-4 text-sm leading-6 text-(--campaign-muted)">
{consentText}
</div>
<div className="mt-5 grid gap-3 rounded-xl bg-[#f9faff] p-4 text-sm">
<div className="flex gap-3">
<Sparkles aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-[#184e92]" />
<p>
<b>A selfie não sai do dispositivo.</b>
<br />
<span className="text-(--campaign-muted)">
A imagem é processada aqui e não é guardada na galeria do site.
</span>
</p>
</div>
<div className="flex gap-3">
<ShieldCheck aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-[#184e92]" />
<p>
<b>Você pode desistir e pedir remoção.</b>
<br />
<span className="text-(--campaign-muted)">
O resultado não mostra nomes de terceiros nem percentual de semelhança.
</span>
</p>
</div>
</div>
<label className="mt-5 flex cursor-pointer items-start gap-3 rounded-xl border border-black/15 p-4 text-sm leading-5">
<input
type="checkbox"
checked={consented}
onChange={(event) => setConsented(event.target.checked)}
className="mt-0.5 size-5 shrink-0 accent-[#184e92]"
/>
<span>
Li e autorizo o uso temporário da minha biometria facial para procurar a mim mesmo
neste álbum.
</span>
</label>
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
Saiba como tratamos seus dados na{' '}
<Link
href="/privacidade"
className="font-bold text-[#184e92] underline underline-offset-2"
>
Política de Privacidade
</Link>
.
</p>
<button
type="button"
disabled={!consented}
onClick={() => startSelfieStep('search')}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-6 w-full disabled:cursor-not-allowed disabled:opacity-55`}
>
Concordar e escolher minha selfie
</button>
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`mt-3 flex min-h-11 w-full items-center justify-center text-sm font-bold text-(--campaign-muted) ${ARCHIVE_PHOTO_FOCUS}`}
>
Agora não · voltar ao álbum
</Link>
</section>
) : null}
{step.kind === 'pick' ? (
<section
ref={pickRef}
tabIndex={-1}
aria-labelledby="selfie-pick-title"
className={`${STEP_SHELL} rounded-2xl border border-(--campaign-line) bg-white p-6 focus:outline-none sm:p-7`}
>
<StepDots current={2} />
<p className="mt-5 text-xs font-black tracking-[0.08em] text-[#184e92] uppercase">
Passo 2 de 3
</p>
<h2
id="selfie-pick-title"
className="mt-1 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black"
>
Escolha uma selfie nítida
</h2>
<p className="mt-2 text-sm leading-5 text-(--campaign-muted)">
Olhe para a câmera, com o rosto bem iluminado e sem outras pessoas na imagem.
</p>
{previewUrl ? (
<div className="relative mt-5 aspect-[4/5] overflow-hidden rounded-2xl border border-black/10 bg-[#f5f5f4]">
{/* eslint-disable-next-line @next/next/no-img-element -- local blob preview, never uploaded */}
<img src={previewUrl} alt="Prévia da sua selfie" className="size-full object-cover" />
<span className="absolute right-3 bottom-3 rounded-full bg-black/55 px-3 py-1.5 text-[10px] font-bold text-white">
Prévia no aparelho
</span>
</div>
) : (
<button
type="button"
onClick={openPicker}
className={`mt-5 flex aspect-[4/5] w-full flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-[#184e92]/30 bg-[#f9faff] text-sm font-bold text-[#184e92] ${ARCHIVE_PHOTO_FOCUS}`}
>
<Camera aria-hidden="true" className="size-7" strokeWidth={2} />
Tirar ou escolher uma foto
</button>
)}
<div className="mt-4 flex items-start gap-3 rounded-xl bg-[#eef4fb] p-3 text-sm text-[#143c70]">
<ShieldCheck aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<p className="leading-5">
<b>Esta foto não sai do seu dispositivo.</b>
<br />O processamento acontece aqui.
</p>
</div>
<button
type="button"
disabled={!file}
onClick={() =>
step.intent === 'leave-index'
? setStep({ kind: 'confirm-leave' })
: runSearch('search')
}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-5 w-full disabled:cursor-not-allowed disabled:opacity-55`}
>
{step.intent === 'leave-index' ? 'Continuar' : 'Usar esta selfie'}
</button>
<button
type="button"
onClick={openPicker}
className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} mt-2 w-full`}
>
<Upload aria-hidden="true" className="size-4" />
Escolher outra foto
</button>
</section>
) : null}
{step.kind === 'processing' ? (
<section
ref={processingRef}
tabIndex={-1}
aria-live="polite"
className={`${STEP_SHELL} flex min-h-[560px] flex-col items-center justify-center rounded-2xl border border-(--campaign-line) bg-white px-6 py-12 text-center focus:outline-none`}
>
<div className="relative">
<div className="grid size-28 place-items-center overflow-hidden rounded-full border-4 border-white bg-[#f5f5f4] shadow-[0_10px_28px_rgb(0_0_0/16%)]">
{previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- local blob preview, never uploaded
<img src={previewUrl} alt="" className="size-full object-cover" />
) : null}
</div>
<div className="absolute -right-2 -bottom-1 grid size-10 place-items-center rounded-full bg-[#eef4fb]">
<span className="size-7 animate-spin rounded-full border-[3px] border-[#184e92]/16 border-t-[#184e92] motion-reduce:animate-none" />
</div>
</div>
<p className="mt-7 text-xs font-black tracking-[0.08em] text-[#184e92] uppercase">
{step.intent === 'leave-index' ? 'Saindo do índice' : 'Passo 3 de 3'}
</p>
<h2 className="mt-2 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black">
{step.intent === 'leave-index'
? 'Confirmando sua saída do índice…'
: 'Procurando você nas fotos aprovadas…'}
</h2>
<p className="mt-3 max-w-[290px] text-sm leading-6 text-(--campaign-muted)">
Mantenha esta tela aberta. Sua selfie continua somente neste aparelho.
</p>
<div className="mt-6 flex items-center gap-2 text-xs font-bold text-[#184e92]">
<ShieldCheck aria-hidden="true" className="size-4" />
Sem upload da imagem · sem cadastro
</div>
</section>
) : null}
{step.kind === 'results' ? (
<section
ref={resultsRef}
tabIndex={-1}
aria-labelledby="selfie-results-title"
className="-mx-5 w-auto bg-white px-5 py-8 focus:outline-none sm:-mx-8 sm:px-8 sm:py-9"
>
<div className="mx-auto w-full max-w-6xl">
<div className="flex flex-col gap-5 border-b border-(--campaign-line) pb-6 sm:flex-row sm:items-start sm:justify-between sm:gap-8">
<div>
<p className="hidden text-xs font-black tracking-[0.1em] text-[#184e92] uppercase sm:block">
Sua busca por selfie
</p>
<h2
id="selfie-results-title"
className="mt-0 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.03em] sm:mt-2 sm:text-[34px]"
>
<span className="sm:hidden">Encontramos você nestas fotos.</span>
<span className="hidden sm:inline">
Estas são as fotos em que encontramos você.
</span>
</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-(--campaign-muted)">
<span className="sm:hidden">
Só registros públicos aprovados, sem nomes de terceiros.
</span>
<span className="hidden sm:inline">
Mostramos apenas registros públicos aprovados. Não exibimos nomes de outras
pessoas nem nota de semelhança.
</span>
</p>
</div>
<button
type="button"
onClick={() => startSelfieStep('search')}
className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} shrink-0`}
>
Usar outra selfie
</button>
</div>
<div className="mt-7 grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3 sm:gap-x-4 sm:gap-y-7">
{step.photos.map((photo) => (
<SelfieSearchResultCard key={photo.id} photo={photo} />
))}
</div>
<PresenceBand onManage={() => setStep({ kind: 'presence' })} />
</div>
</section>
) : null}
{step.kind === 'empty' ? (
<FlowStateCard
icon={<ImageIcon aria-hidden="true" className="size-7" strokeWidth={2} />}
title="Não encontramos fotos suas"
body="Esta busca funciona para quem autorizou participar do índice. Também pode não haver uma foto sua entre os registros públicos aprovados."
note="Não mostramos pessoas parecidas nem tentamos adivinhar quem você é."
action={
<button
type="button"
onClick={() => startSelfieStep('search')}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
>
Tentar outra foto
</button>
}
secondary={
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={STATE_LINK}>
Voltar ao álbum
</Link>
}
/>
) : null}
{step.kind === 'no-face' ? (
<FlowStateCard
icon={<TriangleAlert aria-hidden="true" className="size-7" strokeWidth={2} />}
tone="danger"
title="Não encontramos um rosto nesta selfie"
body="Escolha uma foto de frente, com o rosto bem iluminado e sem outras pessoas na imagem."
note="Nenhuma imagem foi enviada."
action={
<button
type="button"
onClick={() => startSelfieStep('search')}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
>
Escolher outra foto
</button>
}
secondary={
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={STATE_LINK}>
Voltar ao álbum
</Link>
}
/>
) : null}
{step.kind === 'presence' ? (
<section
ref={presenceRef}
tabIndex={-1}
aria-labelledby="selfie-presence-title"
className={`${STEP_SHELL} rounded-2xl border border-(--campaign-line) bg-white p-6 focus:outline-none sm:p-7`}
>
<p className="text-xs font-black tracking-[0.08em] text-[#184e92] uppercase">
Privacidade
</p>
<h2
id="selfie-presence-title"
className="mt-1 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black"
>
Você decide se quer aparecer
</h2>
<p className="mt-2 text-sm leading-6 text-(--campaign-muted)">
Para sair do índice, confirme sua identidade com uma selfie. Para retirar uma foto
pública, use o canal da equipe responsável pelo acervo.
</p>
<div className="mt-5 flex gap-3 rounded-xl border-2 border-[#184e92] bg-[#f9faff] p-4">
<span
aria-hidden="true"
className="mt-0.5 size-5 shrink-0 rounded-full border-[5px] border-[#184e92]"
/>
<span>
<b className="text-sm">Sair do índice de busca</b>
<small className="mt-1 block text-xs leading-5 text-(--campaign-muted)">
Seu rosto deixa de ser usado para localizar fotos por selfie.
</small>
</span>
</div>
{removalChannelUrl ? (
<a
href={removalChannelUrl}
className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} mt-3 w-full items-start justify-between p-4 text-left`}
>
<span>
<b className="text-sm">Pedir remoção de uma foto</b>
<small className="mt-1 block text-xs leading-5 text-(--campaign-muted)">
Abre o canal configurado pela equipe responsável pelo acervo.
</small>
</span>
<ChevronLeft aria-hidden="true" className="mt-0.5 size-4 shrink-0 rotate-180" />
</a>
) : null}
<button
type="button"
onClick={() => startSelfieStep('leave-index')}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-6 w-full`}
>
Continuar
</button>
<button
type="button"
onClick={reset}
className={`mt-4 block w-full text-center text-sm font-bold text-(--campaign-muted) ${ARCHIVE_PHOTO_FOCUS}`}
>
Cancelar
</button>
</section>
) : null}
{step.kind === 'removed' ? (
<FlowStateCard
icon={<ShieldCheck aria-hidden="true" className="size-7" strokeWidth={2} />}
title="Pronto — você saiu do índice"
body="Seu rosto deixou de ser usado para localizar fotos por selfie e o vetor foi apagado."
note="Se quiser voltar a participar, fale com a equipe responsável pelo acervo."
action={
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
>
Voltar ao álbum
</Link>
}
/>
) : null}
{step.kind === 'presence-missing' ? (
<FlowStateCard
icon={<ShieldCheck aria-hidden="true" className="size-7" strokeWidth={2} />}
title="Não encontramos sua presença no índice"
body="Não foi possível reconhecer uma participação sua no índice com esta selfie. Nenhuma alteração foi feita."
action={
<button
type="button"
onClick={() => startSelfieStep('leave-index')}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
>
Tentar outra selfie
</button>
}
secondary={
<>
<p className="text-sm leading-6 text-(--campaign-muted)">
Quer retirar uma foto pública mesmo assim?
</p>
{removalChannelUrl ? (
<a
href={removalChannelUrl}
className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} mt-3 w-full`}
>
Pedir remoção de uma foto
</a>
) : null}
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={`${STATE_LINK} mt-4`}>
Voltar ao álbum
</Link>
</>
}
/>
) : null}
{step.kind === 'unsupported' ? (
<FlowStateCard
icon={<Smartphone aria-hidden="true" className="size-7" strokeWidth={2} />}
title="Este navegador não consegue fazer a busca por selfie"
body="Para proteger sua imagem, o reconhecimento precisa rodar neste aparelho. Este navegador não oferece os recursos necessários."
note="O problema não é com a sua selfie. Nenhuma imagem foi enviada."
action={
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
>
Voltar ao álbum
</Link>
}
secondary={
<p className="text-xs leading-5 text-(--campaign-muted)">
Você pode tentar novamente em outro navegador ou aparelho compatível.
</p>
}
/>
) : null}
{step.kind === 'engine-error' ? (
<FlowStateCard
icon={<TriangleAlert aria-hidden="true" className="size-7" strokeWidth={2} />}
tone="danger"
title="Não foi possível processar a busca"
body="O reconhecimento neste aparelho parou antes de concluir. Sua selfie não foi enviada nem guardada."
action={
<button
type="button"
onClick={() => startSelfieStep('search')}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
>
<RefreshCw aria-hidden="true" className="size-4" />
Tentar novamente
</button>
}
secondary={
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={`${STATE_LINK} mt-4`}>
Voltar ao álbum
</Link>
}
/>
) : null}
{step.kind === 'rate-limited' ? (
<FlowStateCard
icon={<Clock aria-hidden="true" className="size-7" strokeWidth={2} />}
tone="warn"
title="Muitas tentativas em pouco tempo"
body="Para proteger este recurso, a busca foi pausada por alguns minutos."
note="Espere alguns minutos antes de tentar novamente."
action={
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
>
Voltar ao álbum
</Link>
}
/>
) : null}
{step.kind === 'network' ? (
<FlowStateCard
icon={<WifiOff aria-hidden="true" className="size-7" strokeWidth={2} />}
title="Não foi possível enviar a busca"
body="A conexão falhou ao enviar somente os dados necessários para procurar as fotos. Sua selfie continua neste aparelho."
action={
<button
type="button"
onClick={() => runSearch(activeIntent)}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
>
<RefreshCw aria-hidden="true" className="size-4" />
Tentar enviar novamente
</button>
}
secondary={
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={`${STATE_LINK} mt-4`}>
Voltar ao álbum
</Link>
}
/>
) : null}
{step.kind === 'closed' ? <SelfieSearchClosed /> : null}
{step.kind === 'confirm-leave' ? (
<LeaveConfirmDialog
onConfirm={() => runSearch('leave-index')}
onCancel={() => setStep({ kind: 'presence' })}
/>
) : null}
</div>
)
}
const STATE_LINK = `inline-flex min-h-11 items-center text-sm font-bold text-[#184e92] underline underline-offset-4 ${ARCHIVE_PHOTO_FOCUS}`
const StepDots = ({ current }: { current: 2 }) => (
<div aria-hidden="true" className="flex items-center gap-2">
{[1, 2, 3].map((position) => (
<span
key={position}
className={`h-1.5 flex-1 rounded-full ${position <= current ? 'bg-[#184e92]' : 'bg-black/15'}`}
/>
))}
</div>
)
const PresenceBand = ({ onManage }: { onManage: () => void }) => (
<div className="mt-10 flex flex-col items-start justify-between gap-3 rounded-xl bg-(--campaign-cream) p-5 sm:flex-row sm:items-center">
<div>
<p className="font-extrabold">Quer controlar sua presença no álbum?</p>
<p className="mt-1 text-sm text-(--campaign-muted)">
Saia do índice ou peça a remoção de uma foto.
</p>
</div>
<button type="button" onClick={onManage} className={`${STATE_LINK} shrink-0`}>
Gerenciar minha presença
</button>
</div>
)
/**
* The shared terminal-state card (artefato: cenas 06/09–13): the icon circle,
* the honest title/body and the single primary action. `tone` only changes the
* icon palette — hierarchy and spacing stay identical across states.
*/
const FlowStateCard = ({
icon,
tone = 'info',
title,
body,
note,
action,
secondary,
}: {
icon: ReactNode
tone?: 'info' | 'danger' | 'warn'
title: string
body: string
note?: string
action: ReactNode
secondary?: ReactNode
}) => {
const sectionRef = useRef<HTMLElement>(null)
useEffect(() => {
sectionRef.current?.scrollIntoView({ block: 'start' })
sectionRef.current?.focus()
}, [])
const toneClass =
tone === 'danger'
? 'bg-red-50 text-[#b42318]'
: tone === 'warn'
? 'bg-amber-50 text-amber-800'
: 'bg-[#eef4fb] text-[#184e92]'
return (
<section
ref={sectionRef}
tabIndex={-1}
className={`${STEP_SHELL} flex min-h-[560px] flex-col items-center justify-center rounded-2xl border border-(--campaign-line) bg-white px-6 py-12 text-center focus:outline-none`}
>
<div className={`grid size-14 place-items-center rounded-full ${toneClass}`}>{icon}</div>
<h2 className="mt-5 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black text-balance">
{title}
</h2>
<p className="mt-2 max-w-[340px] text-sm leading-6 text-(--campaign-muted)">{body}</p>
{note ? <p className="mt-3 max-w-[340px] text-sm font-bold">{note}</p> : null}
<div className="mt-7 w-full max-w-sm">{action}</div>
{secondary ? <div className="mt-4 w-full max-w-sm">{secondary}</div> : null}
</section>
)
}
/**
* The opt-out confirmation (artefato: cena 08, segundo quadro) — a real dialog:
* the destructive action asks once, Esc and the backdrop cancel, Tab stays
* inside, and focus starts on the cancel action so an accidental Enter never
* erases the index entry.
*/
const LeaveConfirmDialog = ({
onConfirm,
onCancel,
}: {
onConfirm: () => void
onCancel: () => void
}) => {
const cancelRef = useRef<HTMLButtonElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const onCancelRef = useRef(onCancel)
onCancelRef.current = onCancel
useEffect(() => {
cancelRef.current?.focus()
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onCancelRef.current()
return
}
if (event.key !== 'Tab') return
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE)
if (!focusables || focusables.length === 0) return
const first = focusables[0]!
const last = focusables[focusables.length - 1]!
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="selfie-leave-title"
className="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<button
type="button"
aria-label="Fechar sem alterar"
tabIndex={-1}
onClick={onCancel}
className="absolute inset-0 cursor-default bg-[#171412]/55 backdrop-blur-[2px]"
/>
<div className="relative w-full max-w-[420px] rounded-2xl border border-(--campaign-line) bg-white p-5 shadow-[0_24px_70px_rgb(0_0_0/28%)]">
<div className="grid size-11 place-items-center rounded-full bg-red-50 text-[#b42318]">
<Trash2 aria-hidden="true" className="size-5" strokeWidth={2} />
</div>
<h2
id="selfie-leave-title"
className="mt-4 border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black"
>
Confirmar saída do índice?
</h2>
<p className="mt-2 text-sm leading-6 text-(--campaign-muted)">
Vamos reconhecer você neste aparelho e, se houver correspondência, retirar sua presença do
índice de busca.
</p>
<button
type="button"
onClick={onConfirm}
className={`mt-6 flex min-h-11 w-full items-center justify-center rounded-[10px] bg-[#b42318] px-4 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-white ${ARCHIVE_PHOTO_FOCUS}`}
>
Confirmar saída do índice
</button>
<button
ref={cancelRef}
type="button"
onClick={onCancel}
className={`mt-3 flex min-h-11 w-full items-center justify-center text-sm font-bold text-(--campaign-muted) ${ARCHIVE_PHOTO_FOCUS}`}
>
Voltar sem alterar
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,26 @@
import Link from 'next/link'
import { ARCHIVE_PHOTO_ALBUM_PATH } from '@/lib/archivePhotoPublicCatalog'
import type { FaceSearchPhotoView } from '@/lib/faceSearch'
import { ArchivePhotoCardBody } from './ArchivePhotoCard'
import { ARCHIVE_PHOTO_CARD } from './archivePhotoClasses'
/**
* C234 — one photo of the selfie-search result (artefato: cena 05, variante
* revisada): the album's own card body (`ArchivePhotoCardBody`) minus the
* third-party line — the shape of the view model has no `people`, so "Quem
* aparece" cannot leak back in. The card links to the album's own detail
* (`/fotos?foto=<id>`), the single owner of the photo overlay.
*/
export const SelfieSearchResultCard = ({ photo }: { photo: FaceSearchPhotoView }) => (
<article>
<Link
href={`${ARCHIVE_PHOTO_ALBUM_PATH}?foto=${photo.id}`}
className={`${ARCHIVE_PHOTO_CARD} group`}
data-photo-id={photo.id}
>
<ArchivePhotoCardBody item={{ ...photo, peopleLabel: null }} />
</Link>
</article>
)

View file

@ -0,0 +1,159 @@
/**
* C234 — the on-device engine of the selfie search. The visitor's photo never
* leaves the browser: it is decoded here, resized and run through the SAME
* face-api nets the CLI uses (same model ids, same detector options), and only
* the 128-float descriptor is ever sent. The engine is lazily imported — the
* ~2.6 MB of JS plus the ~7 MB of same-origin models are fetched only when the
* visitor actually starts the flow.
*
* Fail-closed probes come before the download: the bundled TFJS runs on the
* WebGL backend for speed, and a browser with WebGL disabled would silently
* crawl on the CPU backend (or blank) — the flow says so up front instead of
* blaming the selfie. The e2e build replaces the engine with a deterministic
* stub (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`, `window.__faceSearchStub`), same
* contract as the card cutout.
*/
import { loadCardPhoto } from '@/components/cards/cardCanvas'
import { supportsWebgl } from '@/components/cards/cardCutout'
import {
FACE_DETECTOR_INPUT_SIZE,
FACE_DETECTOR_SCORE_THRESHOLD,
FACE_MODEL_DIR,
faceStubDescriptorFromBytes,
} from '@/lib/faceSearch'
declare global {
interface Window {
/** Test seam: the e2e build stubs the engine through this flag. */
__faceSearchStub?: 'ok' | 'slow' | 'error'
}
}
const STUB_ENABLED = process.env.NEXT_PUBLIC_FACE_SEARCH_STUB === '1'
const STUB_DELAY_MS = 1500
/** Longest edge sent to the engine; the full-resolution photo never goes in. */
const SELFIE_MAX_EDGE = 1024
export type SelfieDescriptorResult =
| { ok: true; descriptor: number[] }
| { ok: false; reason: 'unsupported' | 'engine' | 'empty' }
/**
* Whether this browser can run the local engine. The caller must invoke it on
* the client (the probe needs `document`); the stub build is always "supported"
* so the e2e suite exercises the flow, not the capability.
*/
export const supportsFaceSearchEngine = (): boolean => {
if (STUB_ENABLED) return true
return supportsWebgl()
}
type FaceApi = typeof import('@vladmandic/face-api')
let facadePromise: Promise<FaceApi> | null = null
/**
* Loads the three nets once per page: `tinyFaceDetector` finds the faces,
* `faceLandmark68Net` aligns them and `faceRecognitionNet` produces the 128-d
* descriptor — the exact trio the CLI loads. A failed load is not cached, so a
* retry re-attempts the download.
*/
const loadFaceApi = (): Promise<FaceApi> => {
if (!facadePromise) {
facadePromise = (async () => {
const faceapi = await import('@vladmandic/face-api')
await Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_DIR),
faceapi.nets.faceLandmark68Net.loadFromUri(FACE_MODEL_DIR),
faceapi.nets.faceRecognitionNet.loadFromUri(FACE_MODEL_DIR),
])
return faceapi
})().catch((error: unknown) => {
facadePromise = null
throw error
})
}
return facadePromise
}
const cappedSize = (width: number, height: number) => {
const longest = Math.max(width, height)
const scale = longest > SELFIE_MAX_EDGE ? SELFIE_MAX_EDGE / longest : 1
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
}
/**
* Descriptor of the LARGEST face in the photo (the person taking the selfie is
* the one closest to the camera). Resolves `empty` when no face is found — the
* flow has an honest state for that, never a generic engine error.
*/
export const computeSelfieDescriptor = async (file: File): Promise<SelfieDescriptorResult> => {
if (STUB_ENABLED) return stubDescriptor(file)
if (!supportsWebgl()) return { ok: false, reason: 'unsupported' }
try {
const faceapi = await loadFaceApi()
const photo = await loadCardPhoto(file)
const { width, height } = cappedSize(photo.naturalWidth, photo.naturalHeight)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
if (!context) return { ok: false, reason: 'engine' }
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = 'high'
context.drawImage(photo, 0, 0, width, height)
// Let the processing state paint before the synchronous inference.
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
const results = await faceapi
.detectAllFaces(
canvas,
new faceapi.TinyFaceDetectorOptions({
inputSize: FACE_DETECTOR_INPUT_SIZE,
scoreThreshold: FACE_DETECTOR_SCORE_THRESHOLD,
}),
)
.withFaceLandmarks()
.withFaceDescriptors()
if (results.length === 0) return { ok: false, reason: 'empty' }
const largest = results.reduce((best, current) =>
current.detection.box.area > best.detection.box.area ? current : best,
)
return { ok: true, descriptor: Array.from(largest.descriptor) }
} catch {
return { ok: false, reason: 'engine' }
}
}
const readStubMode = (): 'ok' | 'slow' | 'error' => {
if (typeof window === 'undefined') return 'ok'
const mode = window.__faceSearchStub
return mode === 'slow' || mode === 'error' ? mode : 'ok'
}
/**
* Test seam (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`, e2e builds only): the same bytes
* always derive the same descriptor, so the spec seeds a subject whose vector
* equals the fixture's without ever loading a model.
*/
const stubDescriptor = async (file: File): Promise<SelfieDescriptorResult> => {
const mode = readStubMode()
await new Promise((resolve) => setTimeout(resolve, mode === 'slow' ? STUB_DELAY_MS : 20))
if (mode === 'error') return { ok: false, reason: 'engine' }
const bytes = new Uint8Array(await file.arrayBuffer())
return { ok: true, descriptor: faceStubDescriptorFromBytes(bytes) }
}

View file

@ -77,5 +77,15 @@ export const PhotoAlbum: GlobalConfig = {
'Link (https://) ou e-mail (mailto:) para pedir a remoção de uma foto. Obrigatório para publicar o álbum ou aprovar uma foto.',
},
},
{
name: 'selfieSearchEnabled',
type: 'checkbox',
label: 'Busca por selfie',
defaultValue: false,
admin: {
description:
'Liga a busca por selfie (/fotos/encontre). Nasce desligada: só abra com o consentimento configurado e o aval jurídico/DPIA registrados (C234).',
},
},
],
}

View file

@ -25,6 +25,13 @@ import { normalizeForSearch } from '@/lib/speechSearch'
export const ARCHIVE_PHOTO_ALBUM_PATH = '/fotos'
/**
* C234 — the selfie search entry (`/fotos/encontre`): a page of the album
* (noindex), gated by the album global's `selfieSearchEnabled` flag and by the
* Consent resolved server-side.
*/
export const ARCHIVE_PHOTO_ALBUM_ENTRY_PATH = `${ARCHIVE_PHOTO_ALBUM_PATH}/encontre`
/** Grid page size (design scene 07: 24 itens; prev/next preserve the facets). */
export const ARCHIVE_PHOTO_ALBUM_PAGE_SIZE = 24

View file

@ -29,6 +29,19 @@ export const CAMPAIGN_PUSH_CONSENT_KEY = 'campanha-notificacoes-push'
export const CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE =
'Consentimento de notificações push ainda não configurado.'
/**
* C234 — the public selfie search on the photo album. Two keys because the
* two biometric uses are distinct: `FACE_SEARCH_CONSENT_KEY` is the temporary
* use of the querying person's face to find themselves in the album (the
* vector is discarded with the request), while `FACE_INDEX_CONSENT_KEY` is
* the enrollment in the search index (the vector is stored until removal).
* Both fail closed: without the Consent row with the exact key the flow is
* closed, and editing the text invalidates every enrolled subject until
* re-consent.
*/
export const FACE_SEARCH_CONSENT_KEY = 'busca-selfie-fotos'
export const FACE_INDEX_CONSENT_KEY = 'busca-selfie-indice'
/**
* S9 — campaign home "novidades" capture (name + WhatsApp, engagement level
* toggle). Fail-closed like every public flow: while the admin has not created

188
src/lib/faceSearch.ts Normal file
View file

@ -0,0 +1,188 @@
/**
* C234 — pure contract of the selfie search over the public photo album.
*
* The visitor's face descriptor is computed on their own device (the selfie
* never leaves it); the server only ever sees the 128-float vector and answers
* with photos, never with a score. This module owns the wire vocabulary (the
* descriptor length, the model id, the intents), the distance math, the
* eligibility of an enrolled subject (active + current model + current consent
* hash — fail-closed on any of the three), the result view model (which by
* SHAPE cannot carry a similarity number or a third-party name) and the
* deterministic stub the e2e build uses in place of the engine.
*
* Scope lock of the gate (PR #1370): only consented/indexed faces exist in the
* index (A/C); the anonymous-archive index (B) is out. The vector of the
* visitor is never persisted — it lives in the request only.
*/
import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog'
/** face-api's faceRecognitionNet descriptor (128 floats, euclidean space). */
export const FACE_SEARCH_DESCRIPTOR_LENGTH = 128
/** The engine+model the enrolled vectors belong to — a match across models is void. */
export const FACE_SEARCH_MODEL = 'face-api@1.7.15/faceRecognitionNet'
/**
* Internal similarity threshold (face-api's usual 0.6 is a permissive default;
* the feature prefers missing a match over showing the wrong person). Never
* serialized: no caller of this contract receives a distance.
*/
export const FACE_SEARCH_MAX_DISTANCE = 0.45
/** Ceiling of photos in one answer; the oldest beyond it stay only in the album. */
export const FACE_SEARCH_RESULT_LIMIT = 60
/**
* Detector tuning shared by the browser engine and the CLI engine: they must
* load the same three nets with the same options, otherwise the descriptors of
* the selfie and of the archive would live in different spaces.
*/
export const FACE_DETECTOR_INPUT_SIZE = 512
export const FACE_DETECTOR_SCORE_THRESHOLD = 0.3
/** Same-origin directory the browser engine loads the model files from. */
export const FACE_MODEL_DIR = '/fotos/modelos'
export const FACE_SEARCH_INTENTS = ['search', 'leave-index'] as const
/**
* A stored/requested descriptor: exactly `FACE_SEARCH_DESCRIPTOR_LENGTH` finite
* numbers, or nothing (fail-closed — a wrong-length or poisoned vector never
* reaches the math).
*/
export const readFaceVector = (value: unknown): number[] | null => {
if (!Array.isArray(value) || value.length !== FACE_SEARCH_DESCRIPTOR_LENGTH) return null
const vector: number[] = []
for (const component of value) {
if (typeof component !== 'number' || !Number.isFinite(component)) return null
vector.push(component)
}
return vector
}
/** Euclidean distance of same-length descriptors; a length mismatch is never a match. */
export const faceEuclideanDistance = (a: readonly number[], b: readonly number[]): number => {
if (a.length !== b.length || a.length === 0) return Number.POSITIVE_INFINITY
let sum = 0
for (let index = 0; index < a.length; index += 1) {
const delta = a[index] - b[index]
sum += delta * delta
}
return Math.sqrt(sum)
}
export type FaceSearchSubject = {
id: number
status?: string | null
model?: string | null
consentHash?: string | null
vector?: unknown
}
/**
* A subject only answers while it is `active`, was enrolled with the model the
* query belongs to and its consent snapshot is still the configured text
* (editing the Consent text invalidates every enrolled descriptor until the
* person is re-consented — fail-closed).
*/
export const faceSubjectIsEligible = (
subject: FaceSearchSubject,
{ model, consentHash }: { model: string; consentHash: string },
): boolean =>
subject.status === 'active' && subject.model === model && subject.consentHash === consentHash
/**
* The best eligible subject strictly under `maxDistance`, or null. Subject
* order breaks nothing: the comparison is strict, so only a closer subject
* replaces the current best.
*/
export const findFaceMatch = ({
vector,
subjects,
model,
consentHash,
}: {
vector: readonly number[]
subjects: readonly FaceSearchSubject[]
model: string
consentHash: string
}): FaceSearchSubject | null => {
let best: FaceSearchSubject | null = null
let bestDistance = FACE_SEARCH_MAX_DISTANCE
for (const subject of subjects) {
if (!faceSubjectIsEligible(subject, { model, consentHash })) continue
const candidate = readFaceVector(subject.vector)
if (!candidate) continue
const distance = faceEuclideanDistance(vector, candidate)
if (distance < bestDistance) {
bestDistance = distance
best = subject
}
}
return best
}
/**
* What one matched photo looks like to the visitor: the album's public view
* minus the third-party names (`people`/`peopleLabel`) and minus anything
* numeric — the shape is the guardrail.
*/
export type FaceSearchPhotoView = {
id: number
alt: string
title: string
takenOn: string | null
shortDateLabel: string | null
municipalityName: string | null
sceneLabel: string | null
metaLabel: string
thumbnailPath: string
mediaPath: string
downloadPath: string
downloadFilename: string
}
export const toFaceSearchPhotoView = (item: ArchivePhotoPublicItem): FaceSearchPhotoView => ({
id: item.id,
alt: item.alt,
title: item.title,
takenOn: item.takenOn,
shortDateLabel: item.shortDateLabel,
municipalityName: item.municipalityName,
sceneLabel: item.sceneLabel,
metaLabel: item.metaLabel,
thumbnailPath: item.thumbnailPath,
mediaPath: item.mediaPath,
downloadPath: item.downloadPath,
downloadFilename: item.downloadFilename,
})
/**
* Deterministic stand-in for the on-device engine (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`,
* e2e builds only): the same bytes always derive the same descriptor, so the
* spec can seed a subject whose vector equals the fixture's without ever
* loading a model. Never used outside the stub seam.
*/
export const faceStubDescriptorFromBytes = (bytes: Uint8Array): number[] => {
let hash = 0x811c9dc5
for (const byte of bytes) {
hash ^= byte
hash = Math.imul(hash, 0x01000193) >>> 0
}
let state = hash || 0x9e3779b9
const vector: number[] = []
for (let index = 0; index < FACE_SEARCH_DESCRIPTOR_LENGTH; index += 1) {
state ^= state << 13
state ^= state >>> 17
state ^= state << 5
state >>>= 0
vector.push((state / 0xffffffff) * 2 - 1)
}
return vector
}

View file

@ -0,0 +1,15 @@
import { z } from 'zod'
import { FACE_SEARCH_DESCRIPTOR_LENGTH, FACE_SEARCH_INTENTS } from '@/lib/faceSearch'
/**
* C234 — wire contract of the public `POST /api/fotos/selfie`. Strict object:
* an extra key is a malformed body, never a silently ignored one. The vector
* must be exactly the on-device descriptor (finite numbers — Zod rejects NaN
* and Infinity natively); the intent names the only two actions the endpoint
* performs. There is no image field and no identity field — by construction.
*/
export const faceSearchRequestSchema = z.strictObject({
vector: z.array(z.number()).length(FACE_SEARCH_DESCRIPTOR_LENGTH),
intent: z.enum(FACE_SEARCH_INTENTS),
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,61 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_face_subject_status" AS ENUM('active', 'removed');
CREATE TABLE "face_subject" (
"id" serial PRIMARY KEY NOT NULL,
"label" varchar NOT NULL,
"consent_id" integer NOT NULL,
"consent_hash" varchar,
"model" varchar,
"enrolled_at" timestamp(3) with time zone,
"vector" jsonb,
"status" "enum_face_subject_status" DEFAULT 'active' NOT NULL,
"removed_at" timestamp(3) with time zone,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "face_subject_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"archive_photo_id" integer
);
ALTER TABLE "archive_photo" ADD COLUMN "faces_checked_at" timestamp(3) with time zone;
ALTER TABLE "archive_photo" ADD COLUMN "faces_checked_key" varchar;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "face_subject_id" integer;
ALTER TABLE "photo_album" ADD COLUMN "selfie_search_enabled" boolean DEFAULT false;
ALTER TABLE "face_subject" ADD CONSTRAINT "face_subject_consent_id_consent_id_fk" FOREIGN KEY ("consent_id") REFERENCES "public"."consent"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "face_subject_rels" ADD CONSTRAINT "face_subject_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."face_subject"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "face_subject_rels" ADD CONSTRAINT "face_subject_rels_archive_photo_fk" FOREIGN KEY ("archive_photo_id") REFERENCES "public"."archive_photo"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "face_subject_consent_idx" ON "face_subject" USING btree ("consent_id");
CREATE INDEX "face_subject_status_idx" ON "face_subject" USING btree ("status");
CREATE INDEX "face_subject_updated_at_idx" ON "face_subject" USING btree ("updated_at");
CREATE INDEX "face_subject_created_at_idx" ON "face_subject" USING btree ("created_at");
CREATE INDEX "face_subject_rels_order_idx" ON "face_subject_rels" USING btree ("order");
CREATE INDEX "face_subject_rels_parent_idx" ON "face_subject_rels" USING btree ("parent_id");
CREATE INDEX "face_subject_rels_path_idx" ON "face_subject_rels" USING btree ("path");
CREATE INDEX "face_subject_rels_archive_photo_id_idx" ON "face_subject_rels" USING btree ("archive_photo_id");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_face_subject_fk" FOREIGN KEY ("face_subject_id") REFERENCES "public"."face_subject"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_face_subject_id_idx" ON "payload_locked_documents_rels" USING btree ("face_subject_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "face_subject" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "face_subject_rels" DISABLE ROW LEVEL SECURITY;
DROP TABLE "face_subject" CASCADE;
DROP TABLE "face_subject_rels" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_face_subject_fk";
DROP INDEX "payload_locked_documents_rels_face_subject_id_idx";
ALTER TABLE "archive_photo" DROP COLUMN "faces_checked_at";
ALTER TABLE "archive_photo" DROP COLUMN "faces_checked_key";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "face_subject_id";
ALTER TABLE "photo_album" DROP COLUMN "selfie_search_enabled";
DROP TYPE "public"."enum_face_subject_status";`)
}

View file

@ -90,6 +90,7 @@ import * as migration_20260926_034118_add_speech_embedding from './20260926_0341
import * as migration_20260926_062039_add_archive_photo from './20260926_062039_add_archive_photo';
import * as migration_20260926_132106_add_archive_photo_catalog from './20260926_132106_add_archive_photo_catalog';
import * as migration_20260926_163248_add_archive_photo_publication from './20260926_163248_add_archive_photo_publication';
import * as migration_20260927_193954_add_face_subject from './20260927_193954_add_face_subject';
export const migrations = [
{
@ -550,6 +551,11 @@ export const migrations = [
{
up: migration_20260926_163248_add_archive_photo_publication.up,
down: migration_20260926_163248_add_archive_photo_publication.down,
name: '20260926_163248_add_archive_photo_publication'
name: '20260926_163248_add_archive_photo_publication',
},
{
up: migration_20260927_193954_add_face_subject.up,
down: migration_20260927_193954_add_face_subject.down,
name: '20260927_193954_add_face_subject'
},
];

View file

@ -96,6 +96,7 @@ export interface Config {
contentPiece: ContentPiece;
contentMedia: ContentMedia;
archivePhoto: ArchivePhoto;
faceSubject: FaceSubject;
contentEvent: ContentEvent;
recording: Recording;
recordingMedia: RecordingMedia;
@ -153,6 +154,7 @@ export interface Config {
contentPiece: ContentPieceSelect<false> | ContentPieceSelect<true>;
contentMedia: ContentMediaSelect<false> | ContentMediaSelect<true>;
archivePhoto: ArchivePhotoSelect<false> | ArchivePhotoSelect<true>;
faceSubject: FaceSubjectSelect<false> | FaceSubjectSelect<true>;
contentEvent: ContentEventSelect<false> | ContentEventSelect<true>;
recording: RecordingSelect<false> | RecordingSelect<true>;
recordingMedia: RecordingMediaSelect<false> | RecordingMediaSelect<true>;
@ -1563,6 +1565,13 @@ export interface ArchivePhoto {
* Tudo o que a busca da lista encontra, normalizado (sem acentos, minúsculas): legenda, texto visível, temas, pessoas, município, álbuns e tags.
*/
searchText?: string | null;
/**
* Estado do lote da busca por selfie (C234). Nunca editado à mão.
*/
faces?: {
checkedAt?: string | null;
checkedKey?: string | null;
};
updatedAt: string;
createdAt: string;
url?: string | null;
@ -1575,6 +1584,58 @@ export interface ArchivePhoto {
focalX?: number | null;
focalY?: number | null;
}
/**
* Pessoas que consentiram entrar no índice da busca por selfie (/fotos/encontre). Operado pela CLI faces:enroll/faces:index; a remoção é registrada no status.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "faceSubject".
*/
export interface FaceSubject {
id: number;
/**
* Como a assessoria identifica a pessoa; nunca aparece no site.
*/
label: string;
/**
* O Consent da adesão ao índice (chave estável busca-selfie-indice).
*/
consent: number | Consent;
/**
* Snapshot do texto aceito no enrollment; se o texto mudar, a pessoa fica inelegível até re-consentir.
*/
consentHash?: string | null;
/**
* Modelo do descriptor (troca de modelo invalida o índice: re-enrollment).
*/
model?: string | null;
/**
* Muda a cada enrollment/re-enrollment; é o que marca o lote como stale (o updated_at muda também quando o lote escreve os vínculos).
*/
enrolledAt?: string | null;
vector?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Removida: pedido de saída atendido — o vetor é apagado e a pessoa deixa de ser encontrada.
*/
status: 'active' | 'removed';
/**
* Data do atendimento da saída do índice.
*/
removedAt?: string | null;
/**
* Derivado do lote (pnpm faces:index): fotos aprovadas em que o descriptor foi encontrado.
*/
matchedPhotos?: (number | ArchivePhoto)[] | null;
updatedAt: string;
createdAt: string;
}
/**
* Eventos anônimos de circulação (abertura, download, compartilhamento).
*
@ -2409,6 +2470,10 @@ export interface PayloadLockedDocument {
relationTo: 'archivePhoto';
value: number | ArchivePhoto;
} | null)
| ({
relationTo: 'faceSubject';
value: number | FaceSubject;
} | null)
| ({
relationTo: 'contentEvent';
value: number | ContentEvent;
@ -3201,6 +3266,12 @@ export interface ArchivePhotoSelect<T extends boolean = true> {
municipalityName?: T;
municipalitySlug?: T;
searchText?: T;
faces?:
| T
| {
checkedAt?: T;
checkedKey?: T;
};
updatedAt?: T;
createdAt?: T;
url?: T;
@ -3213,6 +3284,23 @@ export interface ArchivePhotoSelect<T extends boolean = true> {
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "faceSubject_select".
*/
export interface FaceSubjectSelect<T extends boolean = true> {
label?: T;
consent?: T;
consentHash?: T;
model?: T;
enrolledAt?: T;
vector?: T;
status?: T;
removedAt?: T;
matchedPhotos?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contentEvent_select".
@ -3917,6 +4005,10 @@ export interface PhotoAlbum {
* Link (https://) ou e-mail (mailto:) para pedir a remoção de uma foto. Obrigatório para publicar o álbum ou aprovar uma foto.
*/
removalChannelUrl?: string | null;
/**
* Liga a busca por selfie (/fotos/encontre). Nasce desligada: só abra com o consentimento configurado e o aval jurídico/DPIA registrados (C234).
*/
selfieSearchEnabled?: boolean | null;
updatedAt?: string | null;
createdAt?: string | null;
}
@ -4046,6 +4138,7 @@ export interface SocialFeedSettingsSelect<T extends boolean = true> {
export interface PhotoAlbumSelect<T extends boolean = true> {
published?: T;
removalChannelUrl?: T;
selfieSearchEnabled?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
@ -4098,6 +4191,7 @@ export interface TaskCreateCollectionExport {
| 'contentPiece'
| 'contentMedia'
| 'archivePhoto'
| 'faceSubject'
| 'contentEvent'
| 'recording'
| 'recordingMedia'

View file

@ -26,6 +26,7 @@ import { ContentPiece } from './collections/ContentPiece'
import { ElectionCandidate } from './collections/ElectionCandidate'
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
import { ElectionTally } from './collections/ElectionTally'
import { FaceSubject } from './collections/FaceSubject'
import { GoogleCalendarSync } from './collections/GoogleCalendarSync'
import { InternetSpeechMedia } from './collections/InternetSpeechMedia'
import { Jingle } from './collections/Jingle'
@ -139,6 +140,7 @@ export default buildConfig({
ContentPiece,
ContentMedia,
ArchivePhoto,
FaceSubject,
ContentEvent,
Recording,
RecordingMedia,

View file

@ -0,0 +1,41 @@
import 'server-only'
/**
* Reads a request body with a hard byte ceiling, streaming and aborting as
* soon as the ceiling is crossed — a chunked body without `content-length`
* cannot make a route buffer unbounded input. `null` means "refused".
*
* Single owner of the bounded-read rule for the anonymous public JSON routes
* (the content-events beacon and the selfie search); extracted from the beacon
* route when it earned a second caller.
*/
export const readBoundedRequestBody = async (
request: Request,
maxBytes: number,
): Promise<string | null> => {
const body = request.body
if (!body) return ''
const reader = body.getReader()
const decoder = new TextDecoder()
let text = ''
let bytes = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
bytes += value.byteLength
if (bytes > maxBytes) {
await reader.cancel().catch(() => undefined)
return null
}
text += decoder.decode(value, { stream: true })
}
return text + decoder.decode()
} catch {
await reader.cancel().catch(() => undefined)
return null
}
}

View file

@ -48,9 +48,15 @@ const cleanup = (now: number): void => {
/**
* `true` when the event may be recorded. A null key (no trustworthy IP) and
* any internal failure answer `true` — the fail-open contract.
* any internal failure answer `true` — the fail-open contract. `budget`
* defaults to the beacon's generous window; a stricter caller (the C234 selfie
* search, which answers a membership oracle) passes its own ceiling while
* sharing the same registry, hash and cleanup.
*/
export const checkContentEventRateLimit = (key: string | null): boolean => {
export const checkContentEventRateLimit = (
key: string | null,
budget: number = MAX_EVENTS_PER_WINDOW,
): boolean => {
if (!key) return true
try {
@ -63,7 +69,7 @@ export const checkContentEventRateLimit = (key: string | null): boolean => {
store.set(key, { count: 1, resetAt: now + WINDOW_MS })
return true
}
if (existing.count >= MAX_EVENTS_PER_WINDOW) return false
if (existing.count >= budget) return false
existing.count += 1
return true

View file

@ -0,0 +1,102 @@
import 'server-only'
import type { Payload } from 'payload'
import { FACE_SEARCH_MODEL, readFaceVector } from '@/lib/faceSearch'
/**
* C234 — writes of the subject lifecycle: enrollment (one row per person who
* adhered to the search index — A/C scope of the gate) and the removal that
* serves the person's own opt-out. Enrollment is called only by
* `pnpm faces:enroll`, after the CLI has proven the selfie has exactly one
* detectable face and resolved the enrollment Consent by its stable key; the
* removal is called by the search endpoint after the request's descriptor
* matched the subject (the face IS the authentication). Neither path takes the
* descriptor from any other source and the row is never public.
*
* Re-enrollment (`subjectId`) replaces the descriptor, refreshes the consent
* snapshot and clears the derived links: the previous matches belong to the
* previous descriptor and must never survive it. `enrolledAt` also advances —
* it is the batch's staleness key, so the next `faces:index` recomputes.
*/
export type FaceSubjectEnrollmentInput = {
payload: Payload
label: string
descriptor: readonly number[]
consent: { id: number; contentHash: string }
subjectId?: number | null
}
export type FaceSubjectEnrollment = {
id: number
created: boolean
}
export const enrollFaceSubject = async ({
payload,
label,
descriptor,
consent,
subjectId,
}: FaceSubjectEnrollmentInput): Promise<FaceSubjectEnrollment> => {
const vector = readFaceVector(descriptor)
if (!vector) throw new Error('Descriptor inválido para enrollment.')
const data = {
label,
consent: consent.id,
consentHash: consent.contentHash,
model: FACE_SEARCH_MODEL,
vector,
enrolledAt: new Date().toISOString(),
status: 'active' as const,
removedAt: null,
matchedPhotos: [] as number[],
}
// Intentional bypass: the enrollment CLI is a trusted operator with no
// session; the descriptor is validated above and never crosses the wire.
const common = { depth: 0 as const, overrideAccess: true }
if (subjectId != null) {
const updated = await payload.update({
collection: 'faceSubject',
id: subjectId,
data,
...common,
})
return { id: updated.id, created: false }
}
const created = await payload.create({ collection: 'faceSubject', data, ...common })
return { id: created.id, created: true }
}
/**
* Opt-out: the subject stops answering (the query checks `status`) and stops
* holding biometrics (the vector and the derived links are erased). The row
* itself stays as the attendance record. One row, one update — atomic by
* itself, no transaction needed.
*/
export const removeFaceSubjectFromIndex = async ({
payload,
id,
}: {
payload: Payload
id: number
}): Promise<void> => {
await payload.update({
collection: 'faceSubject',
id,
data: {
status: 'removed',
vector: null,
removedAt: new Date().toISOString(),
matchedPhotos: [],
},
depth: 0,
// Intentional bypass: the search endpoint is anonymous and the removal is
// authorized by the matched descriptor, never by a session.
overrideAccess: true,
})
}

View file

@ -0,0 +1,290 @@
import 'server-only'
import { createHash } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import sharp from 'sharp'
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
import {
FACE_SEARCH_MAX_DISTANCE,
FACE_SEARCH_MODEL,
faceEuclideanDistance,
readFaceVector,
} from '@/lib/faceSearch'
import {
listFaceSubjects,
type FaceSubjectSummary,
} from '@/utilities/faceSubjects/faceSubjectReads'
import { messageOf } from '@/utilities/media/ffmpeg'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import {
downloadPrivateMediaToFile,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
/**
* C234 — the batch index of the selfie search (`pnpm faces:index`). For every
* approved photo it downloads the private original, prepares a small RGB image
* and asks the INJECTED analyzer (the same face-api engine the browser runs)
* for the descriptors of every face. The descriptors are TRANSIENT — they are
* compared against the enrolled subjects and thrown away; only the
* photo↔subject links are persisted, inside one transaction per photo. That is
* the A/C line of the gate: there is no anonymous face index anywhere.
*
* Skipping is honest by construction: the photo's `faces.checkedKey` records
* the model plus the enrollment revision (`enrolledAt`) of every eligible
* subject, so a new/updated/removed subject invalidates every photo and the
* next run recomputes — while the batch's own writes never change the key.
*
* A failure of one photo is returned, never thrown: nothing is written, the
* next run retries it and the receipt names the stage.
*/
/** Long edge sent to the engine; the original never leaves the archive. */
const IMAGE_MAX_EDGE = 1024
export type ArchivePhotoFaceImage = {
pixels: Buffer
width: number
height: number
}
/** The engine seam: `src/` never imports `scripts/`; the CLI injects it. */
export type ArchivePhotoFaceAnalyzer = (image: ArchivePhotoFaceImage) => Promise<number[][]>
/**
* Decodes any stored image (EXIF-rotated, alpha flattened, ≤1024px raw RGB) —
* the exact input both the batch and the enrollment CLI hand to the engine, so
* the selfie and the archive enter the same space.
*/
export const prepareFaceImage = async (filePath: string): Promise<ArchivePhotoFaceImage> => {
const prepared = await sharp(filePath)
.rotate()
.resize({ width: IMAGE_MAX_EDGE, height: IMAGE_MAX_EDGE, fit: 'inside' })
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
return { pixels: prepared.data, width: prepared.info.width, height: prepared.info.height }
}
export type ArchivePhotoFaceIndexItem = {
id: number
filename: string | null
checkedKey: string | null
}
export type ArchivePhotoFaceIndexQueue = {
/** The revision every processed photo is stamped with. */
indexKey: string
/** Subjects the batch will look for (`active` + current model + vector). */
eligibleSubjects: number
totalApproved: number
/** Approved photos whose `faces.checkedKey` is not the current revision. */
stale: number
/** The work list (newest first); capped by `limit` when asked. */
items: ArchivePhotoFaceIndexItem[]
}
export type ArchivePhotoFaceIndexResult =
| { status: 'indexed'; matchedSubjects: number[] }
| {
status: 'failed'
stage: 'download' | 'image' | 'analyze' | 'write'
error: string
}
const isIndexableSubject = (subject: FaceSubjectSummary): boolean =>
subject.status === 'active' &&
subject.model === FACE_SEARCH_MODEL &&
readFaceVector(subject.vector) !== null
/**
* The revision stamped on each processed photo: the model plus the enrollment
* revision of every indexable subject. Deliberately ignores `updatedAt` (the
* batch writes `matchedPhotos`, which would bump it and make every photo stale
* forever) and ignores removed/stale subjects (they are not searched).
*/
const facePhotoIndexKey = (subjects: readonly FaceSubjectSummary[]): string => {
const revisions = subjects
.filter(isIndexableSubject)
.map((subject) => `${subject.id}:${subject.enrolledAt ?? ''}`)
.sort()
return createHash('sha256')
.update([FACE_SEARCH_MODEL, ...revisions].join('\n'))
.digest('hex')
}
/**
* The batch queue: the approved photos with their marker, the subjects that
* will be searched, and the revision to stamp. `refresh` ignores the marker;
* `limit` caps only the returned work list (the counts stay true).
*/
export const listArchivePhotoFaceIndexQueue = async ({
payload,
refresh = false,
limit,
}: {
payload: Payload
refresh?: boolean
limit?: number
}): Promise<ArchivePhotoFaceIndexQueue> => {
const subjects = await listFaceSubjects(payload)
const indexKey = facePhotoIndexKey(subjects)
const photos = await payload.find({
collection: ARCHIVE_PHOTO_SLUG,
where: { publicationStatus: { equals: 'approved' } },
sort: ['-takenOn', '-id'],
depth: 0,
limit: 0,
pagination: false,
select: { filename: true, faces: true },
// Intentional bypass: the batch CLI is a trusted operator with no session.
overrideAccess: true,
})
const rows: ArchivePhotoFaceIndexItem[] = photos.docs.map((doc) => ({
id: doc.id,
filename: doc.filename ?? null,
checkedKey: doc.faces?.checkedKey ?? null,
}))
const staleRows = refresh ? rows : rows.filter((row) => row.checkedKey !== indexKey)
return {
indexKey,
eligibleSubjects: subjects.filter(isIndexableSubject).length,
totalApproved: photos.docs.length,
stale: staleRows.length,
items: limit && limit > 0 ? staleRows.slice(0, limit) : staleRows,
}
}
/**
* One photo: download → prepare → analyze → transactional link update + marker.
* The fresh subject read happens INSIDE the transaction, so an enrollment that
* lands mid-run is respected (the photo is simply reprocessed by the changed
* key on a later run).
*/
export const indexArchivePhotoFaces = async ({
payload,
item,
indexKey,
analyze,
}: {
payload: Payload
item: ArchivePhotoFaceIndexItem
indexKey: string
analyze: ArchivePhotoFaceAnalyzer
}): Promise<ArchivePhotoFaceIndexResult> => {
if (!item.filename) {
return { status: 'failed', stage: 'download', error: 'Foto sem arquivo armazenado.' }
}
const tempDir = await mkdtemp(join(tmpdir(), 'teqo-face-index-'))
const imagePath = join(tempDir, 'original')
try {
try {
await downloadPrivateMediaToFile({
media: { filename: item.filename },
staticDir: resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG),
destinationPath: imagePath,
})
} catch (error) {
return { status: 'failed', stage: 'download', error: messageOf(error, 'erro desconhecido') }
}
let image: ArchivePhotoFaceImage
try {
image = await prepareFaceImage(imagePath)
} catch (error) {
return { status: 'failed', stage: 'image', error: messageOf(error, 'erro desconhecido') }
}
let descriptors: number[][]
try {
descriptors = await analyze(image)
} catch (error) {
return { status: 'failed', stage: 'analyze', error: messageOf(error, 'erro desconhecido') }
}
try {
const matchedSubjects = await writeArchivePhotoFaceMatches({
payload,
photoId: item.id,
descriptors,
indexKey,
})
return { status: 'indexed', matchedSubjects }
} catch (error) {
return { status: 'failed', stage: 'write', error: messageOf(error, 'erro desconhecido') }
}
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
}
/**
* Replaces the links of one photo for every eligible subject and stamps the
* marker — one transaction with a fresh read, so a concurrent enrollment or
* takedown never meets a half-written state.
*/
const writeArchivePhotoFaceMatches = async ({
payload,
photoId,
descriptors,
indexKey,
}: {
payload: Payload
photoId: number
descriptors: number[][]
indexKey: string
}): Promise<number[]> =>
withPayloadTransaction(payload, async ({ req }) => {
const subjects = await listFaceSubjects(payload, req)
const matchedSubjects: number[] = []
for (const subject of subjects) {
if (!isIndexableSubject(subject)) continue
const vector = readFaceVector(subject.vector)
if (!vector) continue
const matched = descriptors.some(
(descriptor) => faceEuclideanDistance(descriptor, vector) < FACE_SEARCH_MAX_DISTANCE,
)
const linked = subject.matchedPhotoIds.includes(photoId)
if (matched) matchedSubjects.push(subject.id)
if (matched === linked) continue
const next = matched
? [...subject.matchedPhotoIds, photoId]
: subject.matchedPhotoIds.filter((id) => id !== photoId)
await payload.update({
collection: 'faceSubject',
id: subject.id,
data: { matchedPhotos: next },
depth: 0,
req,
// Intentional bypass: the batch CLI is a trusted operator with no session.
overrideAccess: true,
})
}
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: photoId,
data: { faces: { checkedAt: new Date().toISOString(), checkedKey: indexKey } },
depth: 0,
req,
context: { faceIndex: true },
// Intentional bypass: the batch CLI is a trusted operator with no session.
overrideAccess: true,
})
return matchedSubjects
})

View file

@ -0,0 +1,136 @@
import 'server-only'
import type { Payload } from 'payload'
import type { FaceSearchSubject } from '@/lib/faceSearch'
/**
* C234 — server reads of the enrolled subjects behind the selfie search. The
* collection is admin-only, so every read here is the Local API without a user
* (the documented anonymous-read bypass, same precedent as
* `archivePhotoReads`): the SELECTOR is the search request itself, never an
* actor. The rows are few (one per adhered person) and the search must see the
* live state — no cache, so an opt-out stops answering immediately.
*/
/** A relationship value at depth 0 is the id; tolerate the populated shape. */
const relationIds = (value: unknown): number[] => {
if (!Array.isArray(value)) return []
const ids: number[] = []
for (const entry of value) {
if (typeof entry === 'number') {
ids.push(entry)
continue
}
if (entry && typeof entry === 'object' && 'id' in entry && typeof entry.id === 'number') {
ids.push(entry.id)
}
}
return ids
}
export type FaceSubjectSummary = {
id: number
status: string | null
model: string | null
consentHash: string | null
enrolledAt: string | null
vector: unknown
matchedPhotoIds: number[]
}
type FaceSubjectDoc = {
id: number
status?: string | null
model?: string | null
consentHash?: string | null
enrolledAt?: string | null
vector?: unknown
matchedPhotos?: unknown
}
const toSummary = (doc: FaceSubjectDoc): FaceSubjectSummary => ({
id: doc.id,
status: doc.status ?? null,
model: doc.model ?? null,
consentHash: doc.consentHash ?? null,
enrolledAt: doc.enrolledAt ?? null,
vector: doc.vector,
matchedPhotoIds: relationIds(doc.matchedPhotos),
})
const faceSubjectSelect = {
status: true,
model: true,
consentHash: true,
enrolledAt: true,
vector: true,
matchedPhotos: true,
} as const
/** Every subject (any status) — the CLI inventory and the batch's fresh read. */
export const listFaceSubjects = async (
payload: Payload,
req?: { transactionID?: number | string },
): Promise<FaceSubjectSummary[]> => {
const result = await payload.find({
collection: 'faceSubject',
sort: ['id'],
depth: 0,
limit: 0,
pagination: false,
select: faceSubjectSelect,
// Intentional bypass: subjects are admin-only configuration and both the
// search (anonymous request) and the CLI (no session) must read them.
overrideAccess: true,
req,
})
return result.docs.map((doc) => toSummary(doc as FaceSubjectDoc))
}
/** Active subjects only — the candidate set of one search. */
export const listSearchableFaceSubjects = async (
payload: Payload,
): Promise<FaceSearchSubject[]> => {
const result = await payload.find({
collection: 'faceSubject',
where: { status: { equals: 'active' } },
sort: ['id'],
depth: 0,
limit: 0,
pagination: false,
select: { status: true, model: true, consentHash: true, vector: true },
// Intentional bypass: the search endpoint answers anonymous requests and
// the selector is the consent-gated query itself, never an actor.
overrideAccess: true,
})
return result.docs.map((doc) => ({
id: doc.id,
status: doc.status ?? null,
model: doc.model ?? null,
consentHash: doc.consentHash ?? null,
vector: doc.vector,
}))
}
/** The approved photos linked to one subject (the search answer). */
export const getFaceSubjectMatchedPhotoIds = async (
payload: Payload,
id: number,
): Promise<number[]> => {
const doc = await payload
.findByID({
collection: 'faceSubject',
id,
depth: 0,
select: { matchedPhotos: true },
// Intentional bypass: same anonymous-read rationale as the list above.
overrideAccess: true,
})
.catch(() => null)
return relationIds((doc as FaceSubjectDoc | null)?.matchedPhotos)
}

View file

@ -0,0 +1,444 @@
import { randomUUID } from 'node:crypto'
import type { APIRequestContext, Page } from '@playwright/test'
import sharp from 'sharp'
import {
FACE_INDEX_CONSENT_KEY,
FACE_SEARCH_CONSENT_KEY,
} from '../../src/lib/campaignConsentKeys.js'
import { FACE_SEARCH_MODEL, faceStubDescriptorFromBytes } from '../../src/lib/faceSearch.js'
import { hashConsentContent } from '../../src/utilities/consentContentHash.js'
import { adminHeaders } from '../helpers/adminApi'
import { seedTestUser } from '../helpers/seedUser'
import { waitForStreamSettled } from './fixtures/campaignE2EFixtures'
import { expect, test } from './fixtures/e2eTest'
/**
* C234 — the selfie search over real HTTP: the entry gated by the album flag,
* the Consent fail-closed, the A/C answer (only the enrolled subject's approved
* photos, never a third-party name), the honest empty and the opt-out by
* matched descriptor. The engine is the deterministic stub
* (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`): the fixture bytes derive the descriptor
* the seeded subject carries, so no model is ever downloaded.
*
* Rows are seeded through the deployed REST API (admin session) so the server
* process runs the real hooks. Serial: this spec owns its faceSubject/Consent
* rows and flips the same `photoAlbum` global as the C233 spec (the project
* depends on `frontendFotos`).
*/
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const REMOVAL_CHANNEL = 'https://example.org/acervo/remocao'
type Headers = Record<string, string>
const createdPhotoIds: number[] = []
const createdSubjectIds: number[] = []
const createdConsentKeys = new Set<string>()
const uniqueMarker = () => randomUUID().slice(0, 8)
const fixtureConsentText = (label: string) => ({
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [{ type: 'text', text: `Consentimento de teste C234 (${label}).`, version: 1 }],
direction: null,
format: '',
indent: 0,
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
})
const jpegWithFill = (marker: string): Promise<Buffer> => {
const byte = Number.parseInt(marker.slice(0, 2), 16) % 200
return sharp({
create: {
width: 96,
height: 96,
channels: 3,
background: { r: 30 + byte, g: 120, b: 40 + (byte % 60) },
},
})
.jpeg()
.toBuffer()
}
const setAlbum = async (
request: APIRequestContext,
headers: Headers,
data: { published: boolean; removalChannelUrl: string | null; selfieSearchEnabled: boolean },
): Promise<void> => {
const response = await request.post(`${BASE_URL}/api/globals/photoAlbum`, { headers, data })
expect(response.ok(), await response.text()).toBeTruthy()
}
const createPhoto = async (
request: APIRequestContext,
headers: Headers,
caption: string,
): Promise<number> => {
const marker = uniqueMarker()
const response = await request.post(`${BASE_URL}/api/archivePhoto`, {
headers,
multipart: {
_payload: JSON.stringify({
flickrId: `c234-e2e-${marker}`,
alt: `Foto de teste C234 ${marker}: ${caption}`,
publicationStatus: 'approved',
takenAt: '2026-09-12 10:20:30',
catalog: { caption, people: ['Jorge Solla'] },
}),
file: {
name: `c234-${marker}.jpg`,
mimeType: 'image/jpeg',
buffer: await jpegWithFill(marker),
},
},
})
expect(response.ok(), await response.text()).toBeTruthy()
const id = ((await response.json()) as { doc: { id: number } }).doc.id
createdPhotoIds.push(id)
return id
}
const ensureConsent = async (
request: APIRequestContext,
headers: Headers,
key: string,
): Promise<{ id: number; text: unknown }> => {
const existing = await request.get(
`${BASE_URL}/api/consent?limit=1&depth=0&where[key][equals]=${encodeURIComponent(key)}`,
{ headers },
)
expect(existing.ok(), await existing.text()).toBeTruthy()
const found = ((await existing.json()) as { docs: { id: number; text: unknown }[] }).docs[0]
if (found) return found
const created = await request.post(`${BASE_URL}/api/consent`, {
headers,
data: { key, text: fixtureConsentText(key) },
})
expect(created.ok(), await created.text()).toBeTruthy()
createdConsentKeys.add(key)
const doc = ((await created.json()) as { doc: { id: number; text: unknown } }).doc
return { id: doc.id, text: doc.text }
}
const createSubject = async (
request: APIRequestContext,
headers: Headers,
data: { vector: number[]; consentId: number; consentHash: string; matchedPhotos: number[] },
): Promise<number> => {
const response = await request.post(`${BASE_URL}/api/faceSubject`, {
headers,
data: {
label: `Pessoa de teste ${uniqueMarker()}`,
consent: data.consentId,
consentHash: data.consentHash,
model: FACE_SEARCH_MODEL,
vector: data.vector,
enrolledAt: new Date().toISOString(),
status: 'active',
matchedPhotos: data.matchedPhotos,
},
})
expect(response.ok(), await response.text()).toBeTruthy()
const id = ((await response.json()) as { doc: { id: number } }).doc.id
createdSubjectIds.push(id)
return id
}
const faceSubjectById = async (
request: APIRequestContext,
headers: Headers,
id: number,
): Promise<{ status: string; vector: unknown }> => {
const response = await request.get(`${BASE_URL}/api/faceSubject/${id}?depth=0`, { headers })
expect(response.ok(), await response.text()).toBeTruthy()
return (await response.json()) as { status: string; vector: unknown }
}
/** Consent card → pick screen with the given fixture, ready for the primary action. */
const reachPickWithFixture = async (
page: Page,
fixture: { name: string; mimeType: string; buffer: Buffer },
{ firstPick = false }: { firstPick?: boolean } = {},
) => {
if (firstPick) {
await page.getByRole('checkbox').check()
await page.getByRole('button', { name: 'Concordar e escolher minha selfie' }).click()
}
await page.locator('input[type="file"]').setInputFiles(fixture)
}
test.describe.configure({ mode: 'serial' })
test.describe('Frontend busca por selfie (C234)', () => {
let headers: Headers
let indexConsent: { id: number; text: unknown }
let indexConsentHash: string
let subjectId: number
let linkedPhoto: number
let draftLinkedPhoto: number
let otherPhoto: number
const marker = uniqueMarker()
type SelfieFixture = { name: string; mimeType: string; buffer: Buffer }
const selfieA: SelfieFixture = {
name: 'selfie-a.jpg',
mimeType: 'image/jpeg',
buffer: Buffer.alloc(0),
}
const selfieB: SelfieFixture = {
name: 'selfie-b.jpg',
mimeType: 'image/jpeg',
buffer: Buffer.alloc(0),
}
test.beforeAll(async ({ request }) => {
await seedTestUser()
headers = await adminHeaders(request, BASE_URL)
selfieA.buffer = await jpegWithFill('11')
selfieB.buffer = await jpegWithFill('77')
await ensureConsent(request, headers, FACE_SEARCH_CONSENT_KEY)
indexConsent = await ensureConsent(request, headers, FACE_INDEX_CONSENT_KEY)
indexConsentHash = hashConsentContent(indexConsent.text)
await setAlbum(request, headers, {
published: true,
removalChannelUrl: REMOVAL_CHANNEL,
selfieSearchEnabled: true,
})
linkedPhoto = await createPhoto(request, headers, `Plenária encontrada ${marker}`)
otherPhoto = await createPhoto(request, headers, `Outra foto aprovada ${marker}`)
draftLinkedPhoto = await createPhoto(request, headers, `Rascunho do índice ${marker}`)
await request.patch(`${BASE_URL}/api/archivePhoto/${draftLinkedPhoto}`, {
headers,
data: { publicationStatus: 'draft' },
})
subjectId = await createSubject(request, headers, {
vector: faceStubDescriptorFromBytes(new Uint8Array(selfieA.buffer)),
consentId: indexConsent.id,
consentHash: indexConsentHash,
matchedPhotos: [linkedPhoto, draftLinkedPhoto],
})
})
test.afterAll(async ({ request }) => {
const cleanupHeaders = await adminHeaders(request, BASE_URL).catch(() => null)
if (cleanupHeaders) {
for (const id of createdSubjectIds.splice(0)) {
await request
.delete(`${BASE_URL}/api/faceSubject/${id}`, { headers: cleanupHeaders })
.catch(() => undefined)
}
for (const id of createdPhotoIds.splice(0)) {
await request
.delete(`${BASE_URL}/api/archivePhoto/${id}`, { headers: cleanupHeaders })
.catch(() => undefined)
}
for (const key of createdConsentKeys) {
const lookup = await request
.get(
`${BASE_URL}/api/consent?limit=1&depth=0&where[key][equals]=${encodeURIComponent(key)}`,
{
headers: cleanupHeaders,
},
)
.catch(() => null)
const doc = lookup ? ((await lookup.json()) as { docs: { id: number }[] }).docs[0] : null
if (doc) {
await request
.delete(`${BASE_URL}/api/consent/${doc.id}`, { headers: cleanupHeaders })
.catch(() => undefined)
}
}
await setAlbum(request, cleanupHeaders, {
published: true,
removalChannelUrl: REMOVAL_CHANNEL,
selfieSearchEnabled: false,
}).catch(() => undefined)
}
})
test('gates the entry on the album flag and the flow on the explicit consent', async ({
page,
}) => {
await page.goto('/fotos')
await waitForStreamSettled(page)
const entry = page.getByRole('link', { name: 'Começar busca por selfie' })
await expect(entry).toBeVisible()
await entry.click()
await page.waitForURL('**/fotos/encontre')
await waitForStreamSettled(page)
await expect(
page.getByRole('heading', { name: 'Sua face é um dado biométrico sensível' }),
).toBeVisible()
const primary = page.getByRole('button', { name: 'Concordar e escolher minha selfie' })
await expect(primary).toBeDisabled()
await page.getByRole('checkbox').check()
await expect(primary).toBeEnabled()
})
test('finds only the approved photos of the matched subject, without third-party names', async ({
page,
}) => {
// `slow` keeps the processing state on screen long enough to be asserted.
await page.addInitScript(() => {
;(window as unknown as { __faceSearchStub?: string }).__faceSearchStub = 'slow'
})
await page.goto('/fotos/encontre')
await waitForStreamSettled(page)
await reachPickWithFixture(page, selfieA, { firstPick: true })
await page.getByRole('button', { name: 'Usar esta selfie' }).click()
await expect(
page.getByRole('heading', { name: 'Procurando você nas fotos aprovadas…' }),
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Estas são as fotos em que encontramos você.' }),
).toBeVisible()
const card = page.locator(`a[data-photo-id="${linkedPhoto}"]`)
await expect(card).toBeVisible()
await expect(card.getByText(`Plenária encontrada ${marker}`)).toBeVisible()
// The draft is linked to the subject but is not public; the other approved
// photo does not contain the enrolled face.
await expect(page.locator(`a[data-photo-id="${draftLinkedPhoto}"]`)).toHaveCount(0)
await expect(page.locator(`a[data-photo-id="${otherPhoto}"]`)).toHaveCount(0)
// The result card never carries the album's "Quem aparece" line.
await expect(card.getByText('Jorge Solla')).toHaveCount(0)
await expect(page.getByText(/nota de semelhança/)).toBeVisible()
})
test('shows the honest empty state for a face that is not in the index', async ({ page }) => {
await page.goto('/fotos/encontre')
await waitForStreamSettled(page)
await reachPickWithFixture(page, selfieB, { firstPick: true })
await page.getByRole('button', { name: 'Usar esta selfie' }).click()
await expect(page.getByRole('heading', { name: 'Não encontramos fotos suas' })).toBeVisible()
await expect(page.getByText(/pessoas parecidas/)).toBeVisible()
})
test('shows the no-match opt-out state with the configured removal channel', async ({ page }) => {
await page.goto('/fotos/encontre')
await waitForStreamSettled(page)
// The presence screen is reached from the result band (the person is in the
// index); the leave attempt then uses a selfie that does not match.
await reachPickWithFixture(page, selfieA, { firstPick: true })
await page.getByRole('button', { name: 'Usar esta selfie' }).click()
await page.getByRole('button', { name: 'Gerenciar minha presença' }).click()
await page.getByRole('button', { name: 'Continuar' }).click()
await page.locator('input[type="file"]').setInputFiles(selfieB)
await page.getByRole('button', { name: 'Continuar' }).click()
await page.getByRole('button', { name: 'Confirmar saída do índice' }).click()
await expect(
page.getByRole('heading', { name: 'Não encontramos sua presença no índice' }),
).toBeVisible()
const channel = page.getByRole('link', { name: 'Pedir remoção de uma foto' })
await expect(channel).toBeVisible()
await expect(channel).toHaveAttribute('href', REMOVAL_CHANNEL)
})
test('serves the opt-out by matched descriptor and stops answering afterwards', async ({
page,
request,
}) => {
await page.goto('/fotos/encontre')
await waitForStreamSettled(page)
await reachPickWithFixture(page, selfieA, { firstPick: true })
await page.getByRole('button', { name: 'Usar esta selfie' }).click()
await page.getByRole('button', { name: 'Gerenciar minha presença' }).click()
await page.getByRole('button', { name: 'Continuar' }).click()
await page.locator('input[type="file"]').setInputFiles(selfieA)
await page.getByRole('button', { name: 'Continuar' }).click()
await expect(page.getByRole('heading', { name: 'Confirmar saída do índice?' })).toBeVisible()
await page.getByRole('button', { name: 'Confirmar saída do índice' }).click()
await expect(page.getByRole('heading', { name: 'Pronto — você saiu do índice' })).toBeVisible()
const subject = await faceSubjectById(request, headers, subjectId)
expect(subject.status).toBe('removed')
expect(subject.vector).toBeNull()
})
test('fails the engine honestly when the local processing breaks', async ({ page }) => {
await page.addInitScript(() => {
;(window as unknown as { __faceSearchStub?: string }).__faceSearchStub = 'error'
})
await page.goto('/fotos/encontre')
await waitForStreamSettled(page)
await reachPickWithFixture(page, selfieA, { firstPick: true })
await page.getByRole('button', { name: 'Usar esta selfie' }).click()
await expect(
page.getByRole('heading', { name: 'Não foi possível processar a busca' }),
).toBeVisible()
await expect(page.getByText(/não foi enviada nem guardada/)).toBeVisible()
})
test('closes the page when the query Consent disappears', async ({ page, request }) => {
const lookup = await request.get(
`${BASE_URL}/api/consent?limit=1&depth=0&where[key][equals]=${FACE_SEARCH_CONSENT_KEY}`,
{ headers },
)
const doc = ((await lookup.json()) as { docs: { id: number }[] }).docs[0]
expect(doc).toBeDefined()
await request.delete(`${BASE_URL}/api/consent/${doc!.id}`, { headers })
try {
await page.goto('/fotos/encontre')
await waitForStreamSettled(page)
await expect(
page.getByRole('heading', { name: 'A busca por selfie está indisponível' }),
).toBeVisible()
} finally {
await ensureConsent(request, headers, FACE_SEARCH_CONSENT_KEY)
}
})
test('removes the entry from the album when the feature flag is off', async ({
page,
request,
}) => {
await setAlbum(request, headers, {
published: true,
removalChannelUrl: REMOVAL_CHANNEL,
selfieSearchEnabled: false,
})
try {
await page.goto('/fotos')
await waitForStreamSettled(page)
await expect(page.getByRole('link', { name: 'Começar busca por selfie' })).toHaveCount(0)
// Through the API context on purpose: the browser would log the
// document's own 404 and trip the console-error guard (same choice as
// the C233 kill-switch test).
expect((await request.get(`${BASE_URL}/fotos/encontre`)).status()).toBe(404)
} finally {
await setAlbum(request, headers, {
published: true,
removalChannelUrl: REMOVAL_CHANNEL,
selfieSearchEnabled: true,
})
}
})
})

View file

@ -0,0 +1,112 @@
import { randomUUID } from 'node:crypto'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
import { FACE_INDEX_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import { FACE_SEARCH_DESCRIPTOR_LENGTH } from '@/lib/faceSearch'
import { getConsentByKey } from '@/utilities/campaignConsent'
import { enrollFaceSubject } from '@/utilities/faceSubjects/faceSubjectEnrollment'
import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest'
import { ARCHIVE_PHOTO_JPEG_BYTES } from './archivePhotoFixture'
/**
* C234 — the fixtures shared by the face-search int specs (subject/index and
* the API): the file-backed archive photo over the real Payload test database
* and the enrolled subject. The two spec files serialize on the same lease
* because the index revision is derived state over EVERY enrolled subject — an
* enrollment in the sibling spec changes the key and would legitimately mark
* photos stale mid-assertion.
*/
export const FACE_SPECS_LEASE_KEY = 'c234-face-specs'
export const FACE_INDEX_LEASE_KEY = 'c234-face-index-consent'
export const FACE_SEARCH_LEASE_KEY = 'c234-face-search-consent'
export const faceTestVector = (value: number): number[] =>
Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => value)
const record = (
flickrId: string,
overrides: Partial<ArchivePhotoImport> = {},
): ArchivePhotoImport => ({
flickrId,
sourceUrl: `https://www.flickr.com/photos/depjorgesolla/${flickrId}/`,
owner: '12345678@N00',
license: '0',
title: `Foto ${flickrId}`,
description: null,
tags: [],
takenAt: '2025-03-14 10:20:30',
postedAt: '2025-03-15T10:00:00.000Z',
albums: [],
geo: null,
exif: [],
originalUrl: `https://live.staticflickr.com/65535/${flickrId}_abcdef_o.jpg`,
originalKind: 'original',
...overrides,
})
/**
* Ingests one archive photo fixture and optionally approves it (approval needs
* the album's removal channel to be configured by the caller).
*/
export const createFaceTestPhoto = async ({
payload,
approved,
prefix,
trackedFlickrIds,
tempDirs,
}: {
payload: Payload
approved: boolean
prefix: string
trackedFlickrIds: Set<string>
tempDirs: string[]
}): Promise<{ id: number; flickrId: string }> => {
const flickrId = `${prefix}-${randomUUID().slice(0, 8)}`
trackedFlickrIds.add(flickrId)
const dir = await mkdtemp(join(tmpdir(), `${prefix}-photo-`))
tempDirs.push(dir)
const filePath = join(dir, `flickr-${flickrId}.jpg`)
await writeFile(filePath, ARCHIVE_PHOTO_JPEG_BYTES)
const created = await ingestArchivePhoto(payload, record(flickrId), { filePath })
if (created.status !== 'created') throw new Error(`fixture not created: ${created.status}`)
if (approved) {
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: created.id,
data: { publicationStatus: 'approved' },
overrideAccess: true,
})
}
return { id: created.id, flickrId }
}
/** Enrolls a subject with the current index Consent resolved by stable key. */
export const enrollFaceTestSubject = async ({
payload,
label,
vector,
trackedSubjectIds,
}: {
payload: Payload
label: string
vector: number[]
trackedSubjectIds: Set<number>
}): Promise<number> => {
const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY)
if (!consent) throw new Error('index consent not configured')
const enrolled = await enrollFaceSubject({
payload,
label,
descriptor: vector,
consent: { id: consent.id, contentHash: consent.contentHash },
})
trackedSubjectIds.add(enrolled.id)
return enrolled.id
}

View file

@ -126,6 +126,17 @@ export const purgeOrphanedConsentRenames = async (
export const CAMPAIGN_INVITE_CONSENT_LEASE_KEY = 'campaign-invite-consent'
export const SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY = 'supporter-registration-consent'
export const SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY = 'supporter-vote-intention-consent'
/**
* Serializes spec files that write the `photoAlbum` global. A Payload global is
* a single row created by the first write; two parallel spec files writing a
* missing global race the creation and the table ends with TWO rows — every
* later read then picks one of them by `created_at` and the specs disagree
* about the state (observed when the C233 album spec and the C234 face specs
* ran in parallel).
*/
export const PHOTO_ALBUM_LEASE_KEY = 'photo-album-global'
/**
* Serializes spec files that treat the election collections as test-owned:
* `electionResultsImport.int.spec.ts` wipes ALL election rows, which would

View file

@ -26,6 +26,7 @@ import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest'
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { PHOTO_ALBUM_LEASE_KEY, withExclusiveTestDatabaseLease } from '../helpers/testDatabaseLease'
// C233 — the public album boundary over the real Payload `teqo_test`: the
// approve/draft/removed gate, the sticky removal, the removal-channel guard,
@ -78,21 +79,28 @@ const createPhoto = async (): Promise<{ id: number; flickrId: string }> => {
return { id: created.id, flickrId }
}
// The `photoAlbum` global is a single row created by the first write: every
// spec file that writes it serializes on the shared lease so parallel forks
// cannot race the creation into two rows (C234 specs write it too).
const setChannel = async (removalChannelUrl: string): Promise<void> => {
await payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true, removalChannelUrl },
overrideAccess: true,
})
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true, removalChannelUrl },
overrideAccess: true,
}),
)
}
/** Published is false here on purpose: the guard refuses an open album with no channel. */
const closeAlbumAndClearChannel = async (): Promise<void> => {
await payload.updateGlobal({
slug: 'photoAlbum',
data: { published: false, removalChannelUrl: null },
overrideAccess: true,
})
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: false, removalChannelUrl: null },
overrideAccess: true,
}),
)
}
const approve = async (id: number): Promise<void> => {

View file

@ -0,0 +1,286 @@
// @vitest-environment node
import { rm } from 'node:fs/promises'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: vi.fn(),
unstable_cache: (fn: unknown) => fn,
}))
import { POST as postSelfie } from '@/app/(frontend)/api/fotos/selfie/route'
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
import { FACE_INDEX_CONSENT_KEY, FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import config from '@/payload.config'
import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment'
import {
indexArchivePhotoFaces,
listArchivePhotoFaceIndexQueue,
} from '@/utilities/faceSubjects/faceSubjectPhotoIndex'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import {
createFaceTestPhoto,
enrollFaceTestSubject,
FACE_INDEX_LEASE_KEY,
FACE_SEARCH_LEASE_KEY,
FACE_SPECS_LEASE_KEY,
faceTestVector,
} from '../helpers/faceIntFixtures'
import {
acquireTestDatabaseLease,
ensureLeasedConsent,
PHOTO_ALBUM_LEASE_KEY,
withExclusiveTestDatabaseLease,
} from '../helpers/testDatabaseLease'
// C234 — the anonymous selfie endpoint over the real Payload `teqo_test`:
// the kill switch and the Consent fail-closed layers, the A/C answer (only
// enrolled subjects, only approved photos, never a score or a third-party
// name), the opt-out by matched descriptor and the anonymous anti-abuse.
let payload: Payload
const createdFlickrIds = new Set<string>()
const createdSubjectIds = new Set<number>()
const tempDirs: string[] = []
installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const CHANNEL = 'https://jorge.solla.example.org/remocao'
const createPhoto = (approved: boolean) =>
createFaceTestPhoto({
payload,
approved,
prefix: 'c234-api',
trackedFlickrIds: createdFlickrIds,
tempDirs,
})
const enrollFixtureSubject = (label: string, vector: number[]) =>
enrollFaceTestSubject({ payload, label, vector, trackedSubjectIds: createdSubjectIds })
const indexPhotoForSubject = async (photoId: number, vector: number[]): Promise<void> => {
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
const result = await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photoId)!,
indexKey: queue.indexKey,
analyze: async () => [vector],
})
if (result.status !== 'indexed') throw new Error(`index fixture failed: ${result.status}`)
}
const setSelfieSearchEnabled = async (enabled: boolean): Promise<void> => {
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true, removalChannelUrl: CHANNEL, selfieSearchEnabled: enabled },
overrideAccess: true,
}),
)
}
const post = (body: unknown, headers: Record<string, string> = {}): Promise<Response> =>
postSelfie(
new Request('http://localhost/api/fotos/selfie', {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(body),
}),
)
describe('selfie search endpoint (C234)', () => {
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
beforeAll(async () => {
payload = await getPayload({ config: await config })
specsLease = await acquireTestDatabaseLease(payload, FACE_SPECS_LEASE_KEY)
await ensureLeasedConsent(payload, {
consentKey: FACE_SEARCH_CONSENT_KEY,
leaseKey: FACE_SEARCH_LEASE_KEY,
})
await ensureLeasedConsent(payload, {
consentKey: FACE_INDEX_CONSENT_KEY,
leaseKey: FACE_INDEX_LEASE_KEY,
})
await setSelfieSearchEnabled(false)
})
afterAll(async () => {
for (const flickrId of createdFlickrIds) {
await payload.delete({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
overrideAccess: true,
})
}
for (const id of createdSubjectIds) {
await payload.delete({ collection: 'faceSubject', id, overrideAccess: true })
}
await setSelfieSearchEnabled(false)
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
await specsLease.release()
})
it('answers closed (404) while the album kill switch is off, before anything else', async () => {
const response = await post({ vector: faceTestVector(0), intent: 'search' })
expect(response.status).toBe(404)
await expect(response.json()).resolves.toEqual({ ok: false, error: 'closed' })
})
it('refuses a cross-origin request and a malformed body', async () => {
const crossOrigin = await post(
{ vector: faceTestVector(0), intent: 'search' },
{ origin: 'https://malicioso.example' },
)
expect(crossOrigin.status).toBe(403)
await setSelfieSearchEnabled(true)
const shortVector = await post({ vector: [0, 1], intent: 'search' })
expect(shortVector.status).toBe(400)
const badIntent = await post({ vector: faceTestVector(0), intent: 'enroll' })
expect(badIntent.status).toBe(400)
const extraKey = await post({ vector: faceTestVector(0), intent: 'search', image: 'x' })
expect(extraKey.status).toBe(400)
const notJson = await postSelfie(
new Request('http://localhost/api/fotos/selfie', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{nope',
}),
)
expect(notJson.status).toBe(400)
await setSelfieSearchEnabled(false)
})
it('fails closed (503) while the query Consent is not configured', async () => {
await setSelfieSearchEnabled(true)
try {
// The C234 specs lease already serializes every writer of this key, so
// the absence window is exclusive by construction.
await payload.delete({
collection: 'consent',
where: { key: { equals: FACE_SEARCH_CONSENT_KEY } },
overrideAccess: true,
})
const response = await post({ vector: faceTestVector(0), intent: 'search' })
expect(response.status).toBe(503)
await expect(response.json()).resolves.toEqual({ ok: false, error: 'consent' })
} finally {
await ensureLeasedConsent(payload, {
consentKey: FACE_SEARCH_CONSENT_KEY,
leaseKey: FACE_SEARCH_LEASE_KEY,
})
await setSelfieSearchEnabled(false)
}
})
it('returns only the approved photos of the matched subject, without names or score', async () => {
const vector = faceTestVector(0)
const subject = await enrollFixtureSubject('Pessoa encontrada', vector)
const approved = await createPhoto(true)
const draft = await createPhoto(false)
await indexPhotoForSubject(approved.id, vector)
// A draft linked by hand must never surface: the endpoint intersects the
// subject's links with the approved public read.
await payload.update({
collection: 'faceSubject',
id: subject,
data: { matchedPhotos: [approved.id, draft.id] },
overrideAccess: true,
})
await setSelfieSearchEnabled(true)
const response = await post({ vector, intent: 'search' })
expect(response.status).toBe(200)
const body = (await response.json()) as {
ok: boolean
found: boolean
photos: { id: number }[]
}
expect(body.ok).toBe(true)
expect(body.found).toBe(true)
expect(body.photos.map((photo) => photo.id)).toEqual([approved.id])
expect(JSON.stringify(body)).not.toMatch(/"people"|"score"|"distance"|"similarity"/i)
// A face nobody enrolled in the index gets the honest empty answer.
const stranger = await post({ vector: faceTestVector(1), intent: 'search' })
await expect(stranger.json()).resolves.toEqual({ ok: true, found: false })
await setSelfieSearchEnabled(false)
await removeFaceSubjectFromIndex({ payload, id: subject })
})
it('serves the opt-out by matched descriptor and stops answering afterwards', async () => {
const vector = faceTestVector(0.25)
const subject = await enrollFixtureSubject('Sai pelo endpoint', vector)
const approved = await createPhoto(true)
await indexPhotoForSubject(approved.id, vector)
await setSelfieSearchEnabled(true)
const before = await post({ vector, intent: 'search' })
await expect(before.json()).resolves.toMatchObject({ found: true })
const leave = await post({ vector, intent: 'leave-index' })
await expect(leave.json()).resolves.toEqual({ ok: true, removed: true })
const stored = await payload.findByID({
collection: 'faceSubject',
id: subject,
depth: 0,
overrideAccess: true,
})
expect(stored.status).toBe('removed')
expect(stored.vector).toBeNull()
const after = await post({ vector, intent: 'search' })
await expect(after.json()).resolves.toEqual({ ok: true, found: false })
// A descriptor that matches nobody cannot remove anybody.
const nobody = await post({ vector: faceTestVector(1), intent: 'leave-index' })
await expect(nobody.json()).resolves.toEqual({ ok: true, removed: false })
await setSelfieSearchEnabled(false)
})
it('throttles the anonymous client well before the beacon budget', async () => {
const headers = { 'x-forwarded-for': '198.51.100.77' }
for (let attempt = 0; attempt < 20; attempt += 1) {
const response = await post({ vector: faceTestVector(0), intent: 'search' }, headers)
expect(response.status).toBe(404)
}
const throttled = await post({ vector: faceTestVector(0), intent: 'search' }, headers)
expect(throttled.status).toBe(429)
await expect(throttled.json()).resolves.toEqual({ ok: false, error: 'rate-limited' })
})
it('keeps the model contract: only the current model can match', async () => {
const vector = faceTestVector(0.75)
const subject = await enrollFixtureSubject('Modelo trocado', vector)
await payload.update({
collection: 'faceSubject',
id: subject,
data: { model: 'outro-modelo@0' },
overrideAccess: true,
})
await setSelfieSearchEnabled(true)
const response = await post({ vector, intent: 'search' })
await expect(response.json()).resolves.toEqual({ ok: true, found: false })
await setSelfieSearchEnabled(false)
})
})

View file

@ -0,0 +1,249 @@
// @vitest-environment node
import { rm } from 'node:fs/promises'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: vi.fn(),
unstable_cache: (fn: unknown) => fn,
}))
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
import { FACE_INDEX_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import { FACE_SEARCH_DESCRIPTOR_LENGTH, FACE_SEARCH_MODEL } from '@/lib/faceSearch'
import config from '@/payload.config'
import { getConsentByKey } from '@/utilities/campaignConsent'
import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment'
import {
indexArchivePhotoFaces,
listArchivePhotoFaceIndexQueue,
} from '@/utilities/faceSubjects/faceSubjectPhotoIndex'
import {
getFaceSubjectMatchedPhotoIds,
listSearchableFaceSubjects,
} from '@/utilities/faceSubjects/faceSubjectReads'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import {
createFaceTestPhoto,
enrollFaceTestSubject,
FACE_INDEX_LEASE_KEY,
FACE_SPECS_LEASE_KEY,
faceTestVector,
} from '../helpers/faceIntFixtures'
import {
acquireTestDatabaseLease,
ensureLeasedConsent,
PHOTO_ALBUM_LEASE_KEY,
withExclusiveTestDatabaseLease,
} from '../helpers/testDatabaseLease'
// C234 — the subject/index boundary over the real Payload `teqo_test`: the
// enrollment write, the batch's transactional link replacement, the marker
// that makes the run resumable, the opt-out that erases the descriptor and the
// A/C rule that only enrolled subjects are ever searched.
let payload: Payload
const createdFlickrIds = new Set<string>()
const createdSubjectIds = new Set<number>()
const tempDirs: string[] = []
installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const CHANNEL = 'https://jorge.solla.example.org/remocao'
const createApprovedPhoto = () =>
createFaceTestPhoto({
payload,
approved: true,
prefix: 'c234',
trackedFlickrIds: createdFlickrIds,
tempDirs,
})
const enrollFixtureSubject = (label: string, vector: number[]) =>
enrollFaceTestSubject({ payload, label, vector, trackedSubjectIds: createdSubjectIds })
const markerOf = async (
id: number,
): Promise<{ checkedAt?: string | null; checkedKey?: string | null }> => {
const doc = await payload.findByID({
collection: ARCHIVE_PHOTO_SLUG,
id,
depth: 0,
overrideAccess: true,
})
return doc.faces ?? {}
}
describe('face subject enrollment and archive index (C234)', () => {
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
beforeAll(async () => {
payload = await getPayload({ config: await config })
specsLease = await acquireTestDatabaseLease(payload, FACE_SPECS_LEASE_KEY)
await ensureLeasedConsent(payload, {
consentKey: FACE_INDEX_CONSENT_KEY,
leaseKey: FACE_INDEX_LEASE_KEY,
})
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true, removalChannelUrl: CHANNEL },
overrideAccess: true,
}),
)
})
afterAll(async () => {
for (const flickrId of createdFlickrIds) {
await payload.delete({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
overrideAccess: true,
})
}
for (const id of createdSubjectIds) {
await payload.delete({ collection: 'faceSubject', id, overrideAccess: true })
}
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
await specsLease.release()
})
it('enrolls a subject with the current model and consent snapshot', async () => {
const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY)
const id = await enrollFixtureSubject('Pessoa de teste', faceTestVector(0))
const stored = await payload.findByID({
collection: 'faceSubject',
id,
depth: 0,
overrideAccess: true,
})
expect(stored.status).toBe('active')
expect(stored.model).toBe(FACE_SEARCH_MODEL)
expect(stored.consentHash).toBe(consent?.contentHash)
expect(stored.vector).toHaveLength(FACE_SEARCH_DESCRIPTOR_LENGTH)
expect(stored.enrolledAt).toBeTruthy()
const searchable = await listSearchableFaceSubjects(payload)
expect(searchable.map((subject) => subject.id)).toContain(id)
})
it('links an approved photo to the enrolled subject and stamps the marker', async () => {
const owner = await enrollFixtureSubject('Dona da foto', faceTestVector(0))
const photo = await createApprovedPhoto()
const queue = await listArchivePhotoFaceIndexQueue({ payload })
expect(queue.eligibleSubjects).toBeGreaterThanOrEqual(1)
expect(queue.items.map((item) => item.id)).toContain(photo.id)
const result = await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photo.id)!,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0)],
})
expect(result).toEqual({ status: 'indexed', matchedSubjects: expect.arrayContaining([owner]) })
expect(await getFaceSubjectMatchedPhotoIds(payload, owner)).toContain(photo.id)
const marker = await markerOf(photo.id)
expect(marker.checkedKey).toBe(queue.indexKey)
expect(marker.checkedAt).toBeTruthy()
// The marker now spares the photo on the next run.
const nextQueue = await listArchivePhotoFaceIndexQueue({ payload })
expect(nextQueue.items.map((item) => item.id)).not.toContain(photo.id)
const refreshed = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
expect(refreshed.items.map((item) => item.id)).toContain(photo.id)
})
it('removes a link that stopped matching and re-adds it when the face matches again', async () => {
const subject = await enrollFixtureSubject('Rosto mutável', faceTestVector(0))
const photo = await createApprovedPhoto()
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
const indexed = await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photo.id)!,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0)],
})
expect(indexed.status).toBe('indexed')
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
const removed = await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photo.id)!,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(1)],
})
expect(removed.status).toBe('indexed')
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).not.toContain(photo.id)
await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photo.id)!,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0)],
})
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
})
it('changes the index key on a new enrollment so every photo is reprocessed', async () => {
const queue = await listArchivePhotoFaceIndexQueue({ payload })
await enrollFixtureSubject('Nova adesão', faceTestVector(0.5))
const nextQueue = await listArchivePhotoFaceIndexQueue({ payload })
expect(nextQueue.indexKey).not.toBe(queue.indexKey)
})
it('erases the descriptor and the links on opt-out', async () => {
const subject = await enrollFixtureSubject('Sai do índice', faceTestVector(0))
const photo = await createApprovedPhoto()
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photo.id)!,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0)],
})
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
await removeFaceSubjectFromIndex({ payload, id: subject })
const stored = await payload.findByID({
collection: 'faceSubject',
id: subject,
depth: 0,
overrideAccess: true,
})
expect(stored.status).toBe('removed')
expect(stored.vector).toBeNull()
expect(stored.removedAt).toBeTruthy()
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toEqual([])
const searchable = await listSearchableFaceSubjects(payload)
expect(searchable.map((candidate) => candidate.id)).not.toContain(subject)
})
it('fails a photo with no stored file instead of writing a link', async () => {
const subject = await enrollFixtureSubject('Sem arquivo', faceTestVector(0))
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
const result = await indexArchivePhotoFaces({
payload,
item: { id: 999_999_999, filename: null, checkedKey: null },
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0)],
})
expect(result).toEqual({ status: 'failed', stage: 'download', error: expect.any(String) })
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toEqual([])
})
})

View file

@ -214,6 +214,10 @@ describe('campaign JSON mutation route convention', () => {
'src/app/(frontend)/api/content-events/route.ts',
'anonymous public beacon (C213): reads no cookie and no session, so there is no CSRF surface to guard — the write is gated by the published-slug read and throttled in memory; explicit isSameOriginRequest is still a bar-raiser, and the campaign-scoped error envelope does not apply',
],
[
'src/app/(frontend)/api/fotos/selfie/route.ts',
'anonymous public selfie search (C234): reads no cookie and no session, so there is no CSRF surface to guard — the request is bounded/parsed strictly, throttled in memory and gated server-side by the album kill switch + Consent; explicit isSameOriginRequest is still a bar-raiser, and the campaign-scoped error envelope does not apply',
],
[
'src/app/(campaign)/campanha/api/ai-chat/route.ts',
'streaming AI endpoint (ReadableStream, not JSON) — cookie-authenticated via campaign-token, origin-checked by cookie path',
@ -438,6 +442,10 @@ describe('src/utilities top-level is pinned', () => {
'activityUi.ts',
'activityViewModels.ts',
'advisorData.ts',
// C234 — bounded streaming body reader shared by the anonymous JSON routes
// (content-events beacon + selfie search); extracted from the beacon route
// when it earned a second caller.
'boundedRequestBody.ts',
'calendarFeed.ts',
'campaignAccess.ts',
'campaignActionContext.ts',

View file

@ -58,4 +58,16 @@ describe('checkContentEventRateLimit', () => {
vi.setSystemTime(new Date('2026-09-23T12:10:00.001Z'))
expect(checkContentEventRateLimit(key)).toBe(true)
})
it('honours a stricter caller budget on the same registry (C234 selfie search)', () => {
const key = 'unit-strict-budget-client'
for (let attempt = 0; attempt < 20; attempt += 1) {
expect(checkContentEventRateLimit(key, 20)).toBe(true)
}
expect(checkContentEventRateLimit(key, 20)).toBe(false)
// Another client still has its own window and the default budget is intact.
expect(checkContentEventRateLimit(`${key}-other`, 20)).toBe(true)
expect(checkContentEventRateLimit('unit-default-budget-client')).toBe(true)
})
})

View file

@ -83,6 +83,11 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => {
// overlay, media route) is a new contract and the migration makes every
// PR of this delivery high-risk (curated only).
'frontendFotos',
// C234 — deliberate: the selfie search page/endpoint (Consent
// fail-closed, only consented subjects, only approved photos) is a new
// contract and the migration makes every PR of this delivery high-risk
// (curated only).
'frontendFotosSelfie',
])
const onDisk = new Set(specNamesOnDisk())
for (const spec of E2E_CURATED_SPECS) {

View file

@ -0,0 +1,139 @@
// @vitest-environment node
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
// C234 — the write guards of the two face CLIs, exercised as subprocesses:
// every refusal must happen before any engine call or DB connection.
const repoRoot = process.cwd()
const enrollScript = join(repoRoot, 'scripts', 'enroll-face-subject.mjs')
const indexScript = join(repoRoot, 'scripts', 'index-archive-faces.mjs')
const run = (scriptPath: string, args: string[], env: Record<string, string> = {}) =>
spawnSync(process.execPath, [scriptPath, ...args], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 30_000,
env: {
...process.env,
NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs',
NODE_ENV: 'production',
VITEST: '',
DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
PAYLOAD_SECRET: 'test-secret',
TEQO_ENV: '',
ALLOW_REMOTE_DB: '',
FACE_ENROLL_CONFIRM: '',
FACE_INDEX_CONFIRM: '',
S3_BUCKET: '',
S3_ENDPOINT: '',
S3_ACCESS_KEY_ID: '',
S3_SECRET_ACCESS_KEY: '',
...env,
},
})
const output = (result: ReturnType<typeof run>) => `${result.stdout}${result.stderr}`
const withS3 = {
S3_BUCKET: 'bucket',
S3_ENDPOINT: 'http://127.0.0.1:3900',
S3_ACCESS_KEY_ID: 'key',
S3_SECRET_ACCESS_KEY: 'secret',
}
describe('faces:enroll write guards (C234)', () => {
const enrollArgs = ['--label', 'Pessoa', '--selfie', '/tmp/nao-existe-234.jpg', '--apply']
it('prints the help even without a database', () => {
const result = run(enrollScript, ['--help'], { DATABASE_URL: '' })
expect(result.status).toBe(0)
expect(result.stdout).toContain('FACE_ENROLL_CONFIRM')
expect(result.stdout).toContain('busca-selfie-indice')
})
it('refuses --apply on a production target without the intent flag', () => {
const result = run(enrollScript, enrollArgs)
expect(result.status).toBe(1)
expect(output(result)).toContain('FACE_ENROLL_CONFIRM=1')
})
it('requires the declared TEQO_ENV once the intent flag is set', () => {
const result = run(enrollScript, enrollArgs, { FACE_ENROLL_CONFIRM: '1' })
expect(result.status).toBe(1)
expect(output(result)).toContain('TEQO_ENV')
})
it('refuses a target database that does not match the declared environment', () => {
const result = run(enrollScript, enrollArgs, {
FACE_ENROLL_CONFIRM: '1',
TEQO_ENV: 'staging',
})
expect(result.status).toBe(1)
expect(output(result)).toContain('≠ "teqo_staging"')
})
it('passes the guards and refuses a missing selfie before the engine/DB', () => {
const result = run(enrollScript, enrollArgs, {
FACE_ENROLL_CONFIRM: '1',
TEQO_ENV: 'production',
})
expect(result.status).toBe(1)
expect(output(result)).toContain('não foi possível ler a selfie')
})
})
describe('faces:index write guards (C234)', () => {
it('prints the help even without a database', () => {
const result = run(indexScript, ['--help'], { DATABASE_URL: '' })
expect(result.status).toBe(0)
expect(result.stdout).toContain('FACE_INDEX_CONFIRM')
expect(result.stdout).toContain('--refresh')
})
it('refuses --apply on a production target without the intent flag', () => {
const result = run(indexScript, ['--apply'])
expect(result.status).toBe(1)
expect(output(result)).toContain('FACE_INDEX_CONFIRM=1')
})
it('requires the declared TEQO_ENV once the intent flag is set', () => {
const result = run(indexScript, ['--apply'], { FACE_INDEX_CONFIRM: '1' })
expect(result.status).toBe(1)
expect(output(result)).toContain('TEQO_ENV')
})
it('refuses --apply without the complete S3_* set on a declared production target', () => {
const result = run(indexScript, ['--apply'], {
FACE_INDEX_CONFIRM: '1',
TEQO_ENV: 'production',
})
expect(result.status).toBe(1)
expect(output(result)).toContain('S3_BUCKET')
})
it('reaches the payload bootstrap with the complete target declared', () => {
const result = run(indexScript, ['--apply'], {
FACE_INDEX_CONFIRM: '1',
TEQO_ENV: 'production',
...withS3,
})
// No production database here: the run must fail while connecting (or
// right after), never with one of the write guards.
expect(result.status).toBe(1)
expect(output(result)).not.toContain('FACE_INDEX_CONFIRM=1')
expect(output(result)).not.toContain('TEQO_ENV=')
})
})

View file

@ -0,0 +1,128 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
FACE_ENROLL_DEFAULT_OUT_DIR,
faceEnrollReportStamp,
formatFaceEnrollReport,
parseFaceEnrollCliArgs,
} from '../../scripts/lib/faceEnrollPlan.mjs'
// C234 — the pure parser/report of `pnpm faces:enroll`: every refusal happens
// before any engine call or DB connection, and the human lines never carry the
// descriptor.
describe('parseFaceEnrollCliArgs', () => {
it('defaults to the dry-run with the standard receipt dir', () => {
const options = parseFaceEnrollCliArgs(['--label', 'Pessoa', '--selfie', '/tmp/selfie.jpg'])
expect(options).toEqual({
apply: false,
label: 'Pessoa',
selfie: '/tmp/selfie.jpg',
subject: null,
out: FACE_ENROLL_DEFAULT_OUT_DIR,
help: false,
})
})
it('parses --apply, --subject, --out and --help', () => {
const options = parseFaceEnrollCliArgs([
'--apply',
'--label',
' Pessoa ',
'--selfie',
'/tmp/selfie.jpg',
'--subject',
'7',
'--out',
'data/outro',
])
expect(options.apply).toBe(true)
expect(options.label).toBe(' Pessoa ')
expect(options.subject).toBe(7)
expect(options.out).toBe('data/outro')
expect(parseFaceEnrollCliArgs(['--help']).help).toBe(true)
})
it('fails closed on missing label/selfie, bad subject and unknown args', () => {
expect(() => parseFaceEnrollCliArgs(['--selfie', '/tmp/a.jpg'])).toThrow(/--label/)
expect(() => parseFaceEnrollCliArgs(['--label', 'Pessoa'])).toThrow(/--selfie/)
expect(() => parseFaceEnrollCliArgs(['--label', ' ', '--selfie', '/tmp/a.jpg'])).toThrow(
/--label/,
)
expect(() =>
parseFaceEnrollCliArgs(['--label', 'P', '--selfie', '/tmp/a.jpg', '--subject', '0']),
).toThrow(/--subject/)
expect(() =>
parseFaceEnrollCliArgs(['--label', 'P', '--selfie', '/tmp/a.jpg', '--nope']),
).toThrow(/argumento desconhecido/)
expect(() => parseFaceEnrollCliArgs(['--label'])).toThrow(/faltou valor/)
expect(() =>
parseFaceEnrollCliArgs(['--label', 'P', '--selfie', '/tmp/a.jpg', '--out', '../etc']),
).toThrow(/--out/)
})
})
describe('faceEnrollReportStamp', () => {
it('is filesystem-safe', () => {
expect(faceEnrollReportStamp('2026-09-27T16:42:01.123Z')).toBe('2026-09-27T16-42-01-123Z')
})
})
describe('formatFaceEnrollReport', () => {
const base = {
runAt: '2026-09-27T16:42:01.123Z',
mode: 'apply',
target: '127.0.0.1/teqo_wt234',
model: 'face-api@1.7.15/faceRecognitionNet',
label: 'Pessoa',
subjectId: 3,
subjectAction: 'created' as const,
faceCount: 1,
allowed: true,
reason: null,
consentKey: 'busca-selfie-indice',
consentHash: 'abc',
durationMs: 10,
}
it('reports the enrollment without ever mentioning a descriptor', () => {
const lines = formatFaceEnrollReport(base)
const text = lines.join('\n')
expect(text).toContain('criada: #3 (Pessoa)')
expect(text).toContain('busca-selfie-indice')
expect(text).toContain('pnpm faces:index --apply')
expect(text).not.toMatch(/descriptor|vetor|vector/i)
})
it('shows the dry-run as a plan, never as a write', () => {
const lines = formatFaceEnrollReport({
...base,
mode: 'plan',
subjectAction: null,
subjectId: null,
})
const text = lines.join('\n')
expect(text).toContain('plano: Pessoa (nada gravado')
expect(text).not.toContain('criada')
})
it('shows the refusal reason and no subject line', () => {
const lines = formatFaceEnrollReport({
...base,
allowed: false,
subjectId: null,
subjectAction: null,
faceCount: 2,
reason: 'mais de um rosto na selfie',
})
const text = lines.join('\n')
expect(text).toContain('RECUSADO: mais de um rosto na selfie')
expect(text).not.toContain('criada')
})
})

View file

@ -0,0 +1,176 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
FACE_INDEX_DEFAULT_OUT_DIR,
faceIndexReportStamp,
formatFaceIndexInventory,
formatFaceIndexReport,
parseFaceIndexCliArgs,
summarizeFaceIndexInventory,
summarizeFaceIndexResults,
} from '../../scripts/lib/faceIndexPlan.mjs'
// C234 — the pure parser/summaries of `pnpm faces:index`: the honest staleness
// accounting and the operator-facing lines.
describe('parseFaceIndexCliArgs', () => {
it('defaults to the dry-run and parses the modes', () => {
expect(parseFaceIndexCliArgs([])).toEqual({
apply: false,
verify: false,
limit: null,
refresh: false,
out: FACE_INDEX_DEFAULT_OUT_DIR,
help: false,
})
const apply = parseFaceIndexCliArgs(['--apply', '--limit', '5', '--refresh', '--out', 'x'])
expect(apply.apply).toBe(true)
expect(apply.limit).toBe(5)
expect(apply.refresh).toBe(true)
expect(apply.out).toBe('x')
expect(parseFaceIndexCliArgs(['--verify']).verify).toBe(true)
expect(parseFaceIndexCliArgs(['--help']).help).toBe(true)
})
it('fails closed on mutual exclusion, bad limit and escaping out', () => {
expect(() => parseFaceIndexCliArgs(['--apply', '--verify'])).toThrow(/mutuamente exclusivos/)
expect(() => parseFaceIndexCliArgs(['--limit', '0'])).toThrow(/--limit/)
expect(() => parseFaceIndexCliArgs(['--limit', 'x'])).toThrow(/--limit/)
expect(() => parseFaceIndexCliArgs(['--out', '../etc'])).toThrow(/--out/)
expect(() => parseFaceIndexCliArgs(['--refresh=1'])).toThrow(/argumento desconhecido/)
expect(() => parseFaceIndexCliArgs(['--limit'])).toThrow(/faltou valor/)
})
})
describe('summarizeFaceIndexResults', () => {
it('counts indexed/linked/failed and aggregates links per subject', () => {
const summary = summarizeFaceIndexResults([
{ photoId: 1, status: 'indexed', matchedSubjects: [3, 4] },
{ photoId: 2, status: 'indexed', matchedSubjects: [3] },
{ photoId: 3, status: 'indexed', matchedSubjects: [] },
{ photoId: 4, status: 'failed', stage: 'download', error: 'sem arquivo' },
])
expect(summary.indexed).toBe(3)
expect(summary.failed).toBe(1)
expect(summary.linkedPhotos).toBe(2)
expect(summary.linksBySubject).toEqual([
{ subjectId: 3, photos: 2 },
{ subjectId: 4, photos: 1 },
])
expect(summary.failures).toEqual([{ photoId: 4, stage: 'download', error: 'sem arquivo' }])
})
})
describe('summarizeFaceIndexInventory', () => {
const queue = { indexKey: 'k', eligibleSubjects: 2, totalApproved: 10, stale: 4 }
const subjects = [
{
id: 1,
status: 'active',
model: 'face-api@1.7.15/faceRecognitionNet',
consentHash: 'hash-atual',
vector: [0],
matchedPhotoIds: [1, 2],
},
{
id: 2,
status: 'active',
model: 'face-api@1.7.15/faceRecognitionNet',
consentHash: 'hash-antigo',
vector: [0],
matchedPhotoIds: [],
},
{
id: 3,
status: 'removed',
model: 'face-api@1.7.15/faceRecognitionNet',
consentHash: 'hash-atual',
vector: null,
matchedPhotoIds: [],
},
]
it('derives the honest gap, the consent staleness and the links breakdown', () => {
const inventory = summarizeFaceIndexInventory({
queue,
subjects,
currentConsentHash: 'hash-atual',
})
expect(inventory).toEqual({
indexKey: 'k',
totalApproved: 10,
indexed: 6,
stale: 4,
eligibleSubjects: 2,
activeSubjects: 2,
removedSubjects: 1,
staleConsentSubjects: 1,
linkedPhotos: 2,
linksBySubject: [{ subjectId: 1, photos: 2 }],
})
})
it('treats a missing current consent as stale for everyone', () => {
const inventory = summarizeFaceIndexInventory({
queue,
subjects,
currentConsentHash: null,
})
expect(inventory.staleConsentSubjects).toBe(2)
})
})
describe('formatting', () => {
it('stamps the receipt name and prints the inventory lines', () => {
expect(faceIndexReportStamp('2026-09-27T16:42:01.123Z')).toBe('2026-09-27T16-42-01-123Z')
const lines = formatFaceIndexInventory(
{
indexKey: 'k',
totalApproved: 10,
indexed: 6,
stale: 4,
eligibleSubjects: 2,
activeSubjects: 2,
removedSubjects: 1,
staleConsentSubjects: 1,
linkedPhotos: 2,
linksBySubject: [{ subjectId: 1, photos: 2 }],
},
'face-api@1.7.15/faceRecognitionNet',
)
const text = lines.join('\n')
expect(text).toContain('fotos aprovadas: 10')
expect(text).toContain('desatualizadas (a processar): 4')
expect(text).toContain('consentimento vencido')
expect(text).toContain('sujeito #1: 2 foto(s)')
})
it('prints the apply summary with failures', () => {
const report = {
mode: 'apply',
target: 'host/db',
model: 'face-api@1.7.15/faceRecognitionNet',
queue: { items: 3, totalApproved: 10, eligibleSubjects: 1 },
summary: {
indexed: 2,
failed: 1,
linkedPhotos: 1,
linksBySubject: [{ subjectId: 3, photos: 1 }],
failures: [{ photoId: 4, stage: 'analyze', error: 'engine fora' }],
},
}
const text = formatFaceIndexReport(report).join('\n')
expect(text).toContain('modo: apply')
expect(text).toContain('processadas: 2 | vinculadas a alguém: 1 | falharam: 1')
expect(text).toContain('sujeito #3: 1 foto(s)')
expect(text).toContain('✗ foto 4 [analyze]: engine fora')
})
})

View file

@ -0,0 +1,180 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog'
import {
FACE_SEARCH_DESCRIPTOR_LENGTH,
FACE_SEARCH_MAX_DISTANCE,
FACE_SEARCH_MODEL,
faceEuclideanDistance,
faceStubDescriptorFromBytes,
faceSubjectIsEligible,
findFaceMatch,
readFaceVector,
toFaceSearchPhotoView,
} from '@/lib/faceSearch'
// C234 — the pure contract of the selfie search: vector validation, the
// euclidean math, the eligibility of an enrolled subject and the result view
// model that by shape cannot carry a score or a third-party name.
const vectorOf = (value: number, length = FACE_SEARCH_DESCRIPTOR_LENGTH): number[] =>
Array.from({ length }, () => value)
const subjectOf = (
overrides: Partial<{
id: number
status: string
model: string
consentHash: string | null
vector: unknown
}> = {},
) => ({
id: 1,
status: 'active',
model: FACE_SEARCH_MODEL,
consentHash: 'hash-atual',
vector: vectorOf(0),
...overrides,
})
describe('readFaceVector', () => {
it('accepts exactly the descriptor length of finite numbers', () => {
expect(readFaceVector(vectorOf(0.25))).toHaveLength(FACE_SEARCH_DESCRIPTOR_LENGTH)
})
it('fails closed on anything else', () => {
expect(readFaceVector(null)).toBeNull()
expect(readFaceVector('vector')).toBeNull()
expect(readFaceVector([1, 2, 3])).toBeNull()
expect(readFaceVector(vectorOf(0).slice(1))).toBeNull()
expect(readFaceVector([...vectorOf(0).slice(1), Number.NaN])).toBeNull()
expect(readFaceVector([...vectorOf(0).slice(1), Number.POSITIVE_INFINITY])).toBeNull()
expect(readFaceVector([...vectorOf(0).slice(1), '1'])).toBeNull()
})
})
describe('faceEuclideanDistance', () => {
it('is the plain euclidean distance of same-length descriptors', () => {
expect(faceEuclideanDistance([0, 0], [0, 0])).toBe(0)
expect(faceEuclideanDistance([0, 0], [3, 4])).toBe(5)
})
it('is Infinity when the descriptors cannot be compared', () => {
expect(faceEuclideanDistance([0, 0], [0])).toBe(Number.POSITIVE_INFINITY)
expect(faceEuclideanDistance([], [])).toBe(Number.POSITIVE_INFINITY)
})
})
describe('faceSubjectIsEligible', () => {
const current = { model: FACE_SEARCH_MODEL, consentHash: 'hash-atual' }
it('requires active status, the current model and the current consent hash', () => {
expect(faceSubjectIsEligible(subjectOf(), current)).toBe(true)
expect(faceSubjectIsEligible(subjectOf({ status: 'removed' }), current)).toBe(false)
expect(faceSubjectIsEligible(subjectOf({ model: 'outro-modelo' }), current)).toBe(false)
expect(faceSubjectIsEligible(subjectOf({ consentHash: 'hash-antigo' }), current)).toBe(false)
expect(faceSubjectIsEligible(subjectOf({ consentHash: null }), current)).toBe(false)
})
})
describe('findFaceMatch', () => {
const current = { model: FACE_SEARCH_MODEL, consentHash: 'hash-atual' }
it('returns the closest eligible subject strictly under the threshold', () => {
const far = subjectOf({ id: 1, vector: [...vectorOf(0).slice(1), 1] })
const near = subjectOf({ id: 2, vector: [...vectorOf(0).slice(1), 0.1] })
const match = findFaceMatch({
vector: vectorOf(0),
subjects: [far, near],
...current,
})
expect(match?.id).toBe(2)
})
it('ignores ineligible subjects and subjects with a malformed vector', () => {
const removed = subjectOf({ id: 1, status: 'removed', vector: vectorOf(0) })
const staleModel = subjectOf({ id: 2, model: 'outro', vector: vectorOf(0) })
const staleConsent = subjectOf({ id: 3, consentHash: 'antigo', vector: vectorOf(0) })
const broken = subjectOf({ id: 4, vector: [1, 2, 3] })
const eligible = subjectOf({ id: 5, vector: vectorOf(0) })
const match = findFaceMatch({
vector: vectorOf(0),
subjects: [removed, staleModel, staleConsent, broken, eligible],
...current,
})
expect(match?.id).toBe(5)
})
it('never matches at or beyond the threshold and answers null when nothing is close', () => {
const atThreshold = subjectOf({
id: 1,
vector: [...vectorOf(0).slice(1), FACE_SEARCH_MAX_DISTANCE],
})
expect(findFaceMatch({ vector: vectorOf(0), subjects: [atThreshold], ...current })).toBeNull()
const far = subjectOf({
id: 2,
vector: [...vectorOf(0).slice(1), FACE_SEARCH_MAX_DISTANCE + 1],
})
expect(findFaceMatch({ vector: vectorOf(0), subjects: [far], ...current })).toBeNull()
expect(findFaceMatch({ vector: vectorOf(0), subjects: [], ...current })).toBeNull()
})
})
describe('toFaceSearchPhotoView', () => {
const item: ArchivePhotoPublicItem = {
id: 7,
alt: 'Plenária na Câmara',
title: 'Plenária pela saúde',
takenOn: '2026-09-12T12:00:00.000Z',
dateLabel: '12 de setembro de 2026',
shortDateLabel: '12 set 2026',
municipalityName: 'Feira de Santana',
municipalitySlug: 'feira-de-santana',
scene: 'plenaria',
sceneLabel: 'Plenária',
people: ['Jorge Solla', 'Outra Pessoa'],
peopleLabel: 'Jorge Solla e Outra Pessoa',
metaLabel: '12 set 2026 · Feira de Santana · Plenária',
searchText: 'plenaria feira de santana',
thumbnailPath: '/fotos/7/midia?tamanho=grade',
mediaPath: '/fotos/7/midia',
downloadPath: '/fotos/7/midia?download=1',
downloadFilename: 'jorge-solla-1313-foto-7.jpg',
}
it('carries the photo context but never third-party names', () => {
const view = toFaceSearchPhotoView(item) as Record<string, unknown>
expect(view.id).toBe(7)
expect(view.title).toBe('Plenária pela saúde')
expect(view.metaLabel).toBe('12 set 2026 · Feira de Santana · Plenária')
expect(view.thumbnailPath).toBe('/fotos/7/midia?tamanho=grade')
expect(view).not.toHaveProperty('people')
expect(view).not.toHaveProperty('peopleLabel')
expect(Object.keys(view).join(' ')).not.toMatch(/score|similar|distance|percent/i)
})
})
describe('faceStubDescriptorFromBytes', () => {
it('is deterministic, descriptor-sized and bounded', () => {
const bytes = Uint8Array.from([1, 2, 3, 4, 5])
const first = faceStubDescriptorFromBytes(bytes)
const second = faceStubDescriptorFromBytes(bytes)
expect(first).toHaveLength(FACE_SEARCH_DESCRIPTOR_LENGTH)
expect(first).toEqual(second)
expect(first.every((value) => value >= -1 && value <= 1)).toBe(true)
})
it('derives different descriptors for different bytes', () => {
const a = faceStubDescriptorFromBytes(Uint8Array.from([1, 2, 3]))
const b = faceStubDescriptorFromBytes(Uint8Array.from([3, 2, 1]))
expect(a).not.toEqual(b)
expect(faceEuclideanDistance(a, b)).toBeGreaterThan(FACE_SEARCH_MAX_DISTANCE)
})
})

View file

@ -0,0 +1,52 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { FACE_SEARCH_DESCRIPTOR_LENGTH } from '@/lib/faceSearch'
import { faceSearchRequestSchema } from '@/lib/schemas/faceSearch'
// C234 — the wire contract of the selfie endpoint: strict object, exact
// descriptor length, finite numbers only and the two intents. Everything else
// is a malformed body (never a silently ignored field).
const vector = (): number[] => Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => 0.5)
describe('faceSearchRequestSchema', () => {
it('accepts both intents with a full descriptor', () => {
expect(faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'search' }).success).toBe(
true,
)
expect(
faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'leave-index' }).success,
).toBe(true)
})
it('rejects unknown intents and extra keys', () => {
expect(faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'enroll' }).success).toBe(
false,
)
expect(
faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'search', image: 'x' }).success,
).toBe(false)
})
it('rejects descriptors of the wrong length or with non-finite values', () => {
expect(
faceSearchRequestSchema.safeParse({ vector: vector().slice(1), intent: 'search' }).success,
).toBe(false)
expect(
faceSearchRequestSchema.safeParse({
vector: [...vector().slice(1), Number.NaN],
intent: 'search',
}).success,
).toBe(false)
expect(
faceSearchRequestSchema.safeParse({
vector: [...vector().slice(1), Number.POSITIVE_INFINITY],
intent: 'search',
}).success,
).toBe(false)
expect(faceSearchRequestSchema.safeParse({ vector: {}, intent: 'search' }).success).toBe(false)
expect(faceSearchRequestSchema.safeParse({ intent: 'search' }).success).toBe(false)
})
})