feat(C242/C243): abre a busca por selfie a qualquer visitante (escopo B) + seção na homepage

O escopo A/C do gate (PR #1370) é superado por decisão do dono (2026-10-01, aval jurídico declarado): o índice vira anônimo (archivePhotoFace, foto + descritor 128-d, sem nome/vínculo), o enrollment por pessoa morre (faceSubject/faces:enroll removidos) e qualquer visitante encontra as próprias fotos aprovadas.

- faces:index persiste os descritores por foto (transação substitui as linhas + marker = modelo); desaprovar/remover/deletar foto apaga os descritores (hooks + FK cascade)
- POST /api/fotos/selfie casa o vetor da consulta contra o índice em memória (TTL 60 s) e devolve só a interseção com o read aprovado do C233, sem score/nome de terceiro; leave-index apaga os descritores que casarem
- Consentimentos seguem fail-closed: busca-selfie-fotos (consulta) e busca-selfie-indice (aviso público do índice), textos versionados em scripts/lib/faceConsentTexts.mjs e provisionados por pnpm seed:face-consents
- pnpm archive:publish aprova o acervo em lote (draft -> approved; removed nunca) com guardas e recibo
- C243: seção condicional SelfieSearchHomeSection (port do design hi-fi, cenas 390/1280) entre a Central e a newsletter; sem engine/captura na home e sem CTA morto quando fechada
- docs: planos C242/C243, AGENTS-public, runbook §C242, changelog

Provas: gate:fast 457 arquivos/5.055 testes; int full 109/1.075; e2e frontendFotosSelfie 9/9; knip verde.
This commit is contained in:
Francisco Solla 2026-10-01 05:27:28 -03:00
parent ca843fb4bb
commit cc4b344a3b
55 changed files with 23591 additions and 1897 deletions

View file

@ -6,13 +6,13 @@ 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
## Selfie search (`/fotos/encontre`) — C234/C242
The visitor's photo never leaves their device: the browser runs the face-api nets locally (models served same-origin from `/fotos/modelos`, copied by `scripts/copy-face-vision-assets.mjs` in prebuild/predev/Docker) and posts only the 128-float descriptor to `POST /api/fotos/selfie` (`{ vector, intent: 'search' | 'leave-index' }`, JSON ≤4 KB, same-origin, rate-limited 20/10 min per hashed IP with its own key namespace). The answer is a list of **approved** photos (`{ ok, found, photos }`), never a score, a percentage or a third-party name — the view model (`src/lib/faceSearch.ts` `toFaceSearchPhotoView`) has no such fields by shape. Recusals: 400 body, 403 origin, 429 throttle, 404 when `photoAlbum.published === false` or `selfieSearchEnabled !== true`, 503 when the query Consent is missing.
The visitor's photo never leaves their device: the browser runs the face-api nets locally (models served same-origin from `/fotos/modelos`, copied by `scripts/copy-face-vision-assets.mjs` in prebuild/predev/Docker) and posts only the 128-float descriptor to `POST /api/fotos/selfie` (`{ vector, intent: 'search' | 'leave-index' }`, JSON ≤4 KB, same-origin, rate-limited 20/10 min per hashed IP with its own key namespace). The answer is a list of **approved** photos (`{ ok, found, photos }`), never a score, a percentage or a third-party name — the view model (`src/lib/faceSearch.ts` `toFaceSearchPhotoView`) has no such fields by shape. Recusals: 400 body, 403 origin, 429 throttle, 404 when `photoAlbum.published === false` or `selfieSearchEnabled !== true`, 503 when either Consent is missing. Since C243 the public homepage carries a conditional discovery section (`SelfieSearchHomeSection`) that only presents and links — no capture/engine there.
**The index is 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`).
**The index is scope B (owner decision, 2026-10-01, supersedes the A/C gate of PR #1370): an anonymous descriptor index of the approved archive, so ANY visitor can find themselves.** The batch (`pnpm faces:index --apply|--verify`, maintenance service with the C231/C232 write guards) downloads each approved photo, detects faces with the same engine and persists identity-free rows in `archivePhotoFace` (photo + 128-float vector + model); `archivePhoto.faces.checkedKey` stores only the model, so a model change marks everything stale. There is no enrollment and no per-person consent: the `Consent` rows are the visitor's query consent and the public index notice. The rows die with the public status — the collection hooks purge them when a photo stops being `approved` or is deleted (plus the FK `ON DELETE CASCADE`) — and the answer always intersects with the C233 approved read. The CLI engine (`scripts/lib/faceEngine.mjs`) loads the same nets from `node_modules`.
Both sides are **fail-closed**: `selfieSearchEnabled` defaults to `false`, 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.
Both sides are **fail-closed**: `selfieSearchEnabled` defaults to `false`; the two Consent keys (`busca-selfie-fotos` query consent, `busca-selfie-indice` public index notice, in `src/lib/campaignConsentKeys.ts`) are provisioned by `pnpm seed:face-consents` from the texts versioned in `scripts/lib/faceConsentTexts.mjs` — without both admin rows the page renders the closed state and the API refuses. Opt-out is self-service ("Minha presença" → face match deletes the matching descriptors, so the face stops answering); photo takedown keeps using `photoAlbum.removalChannelUrl`. Opening the surface also requires approving the archive (`pnpm archive:publish`, bulk `draft → approved`, never touching `removed`). E2E builds stub the engine with `NEXT_PUBLIC_FACE_SEARCH_STUB=1` (`window.__faceSearchStub`); the real engine is exercised by the CLI canary and manually. Runbook: `docs/ops/teqo-1313-deploy.md` §C242.
## Posts & Tags (news / publications)

View file

@ -294,6 +294,8 @@ This file is the living owner of Teqo's visual decisions — a working document,
**Multi-media content gallery (C240, 2026-10-01).** A public piece with multiple media uses one stable stage plus position (“N de M”), previous/next controls, and a horizontally scrollable thumbnail strip; never shrink every thumbnail to force the whole set on screen. Download belongs to the selected media, while the single primary action shares the official source post — do not offer an ambiguous “download all” or make media-sharing compete with the source link. Partial arrival is an internal neutral processing state; the public gallery appears only when the complete set is ready. This keeps the action model honest across image-only, mixed video, and large carousels without inventing a second album product.
**Public light-surface focus separation (C243, 2026-10-01).** When the campaign-yellow focus outline sits on a cream, white, or yellow-tinted public surface, keep the 3px yellow signal but add a dark blue outer separator. Yellow alone does not separate reliably from these backgrounds; the second edge preserves the archive's established focus identity while making keyboard location unmistakable without turning blue into a competing action color.
How rules change:
- **Decide, then update the owner.** When a better pattern wins — anti-slop review (`design-taste-frontend` / `gpt-taste`), a reference study (`awesome-design-md`), a redesign pass (`redesign-existing-projects`), or implementation judgment — update this file in the **same change** and say why. A pattern that only lives in code or in an agent's head is drift, not design.

View file

@ -0,0 +1 @@
**C242/C243 — busca por selfie aberta a qualquer visitante (escopo B) + seção na homepage (2026-10-01):** o C234 entregou a busca **fechada** e no escopo A/C (só quem era inscrito pela assessoria se achava); por decisão do dono na sessão de 2026-10-01, com aval jurídico declarado, o escopo do gate (PR #1370) foi reaberto para **B**: o índice passa a ser **anônimo** — descritores 128-d extraídos dos rostos das fotos **aprovadas** do acervo, persistidos sem nome e sem vínculo com pessoa na collection nova `archivePhotoFace` (`admin.hidden`, admin-only), para que **qualquer visitante** encontre as próprias fotos. O enrollment A/C morre (collection `faceSubject`, `pnpm faces:enroll` e a adesão por pessoa foram removidos; migration `20261001_040904_add_archive_photo_face` cria a tabela nova com FK `ON DELETE CASCADE`, dropa a antiga — 0 linhas em produção — e o gerador reordenado à mão para o `CASCADE` do drop não engolir o `DROP CONSTRAINT`). O lote `pnpm faces:index` agora **persiste** os descritores (transação por foto substitui as linhas + marker `faces.checkedKey` = modelo); desaprovar/remover/deletar foto apaga os descritores nos hooks da collection (e o FK cobre SQL direto); o endpoint `POST /api/fotos/selfie` casa o vetor da consulta contra o índice carregado em memória (TTL 60 s, `Float32Array`, teto defensivo) e devolve só a interseção com o read aprovado do C233 — sem score, sem nome de terceiro; `leave-index` apaga os descritores que casarem (opt-out de fato). **Fail-closed mantido:** `selfieSearchEnabled` segue `false`, e os dois Consentimentos continuam obrigatórios — `busca-selfie-fotos` (consentimento da consulta, reescrito) e `busca-selfie-indice` (reclassificado como **aviso público do índice**, sem `consentHash`/elegibilidade por pessoa), com os textos versionados em `scripts/lib/faceConsentTexts.mjs` e provisionados por `pnpm seed:face-consents` (guarda `TEQO_ENV`/`FACE_CONSENT_SEED_CONFIRM`); a página exige os dois e mostra o aviso completo em `<details>`. Ops ganhou `pnpm archive:publish` (lote `draft → approved`, nunca toca `removed`, exige canal de remoção válido, guardas + recibo) para as 6.492 fotos nunca publicadas; o runbook §C242 documenta a abertura na ordem (canal → consentimentos → lote → indexação → flag) e o rollback. **C243:** a homepage pública ganha a seção condicional `SelfieSearchHomeSection` (design hi-fi `docs/plans/busca-selfie-secao-home-ui-design.html`, cenas 390/1280) entre a Central e a newsletter — só apresenta e linka (sem engine/captura/fotos/contadores na home), renderizada apenas com álbum publicado + flag + fotos aprovadas (fail-closed: fechada não deixa CTA morto). Copy do fluxo/entrada atualizada para o escopo B (o artefato `busca-fotos-por-selfie-ui-design.html` foi estendido pelo `designer`, que também atualizou `DESIGN.md` §7). Provas: unit 456 arquivos/5.050 testes verdes (novos: `findFaceDescriptorPhotoIds`, planos dos CLIs, textos de consentimento, gating da home), int full verde 109 arquivos/1.075 testes (índice/purges/endpoint/opt-out/rate limit), e2e `frontendFotosSelfie` atualizado (descritores em vez de subjects; opt-out por linhas; seção da home aberta/fechada). Pendente de ops (pós-deploy): canal de remoção, `seed:face-consents --apply`, `archive:publish --apply` + bust da tag, `faces:index --apply` e a flag. Planos: `docs/plans/busca-selfie-escopo-b.md`/`-impl.md` e `docs/plans/busca-selfie-secao-home.md`/`-impl.md`.

View file

@ -1452,64 +1452,76 @@ 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
## C242 — busca por selfie aberta (índice anônimo do acervo)
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).
A busca por selfie (`/fotos/encontre` + `POST /api/fotos/selfie`) cobre **qualquer
visitante**: o índice é anônimo (foto + descritor 128-d em `archivePhotoFace`),
sem nome, sem vínculo com pessoa e sem adesão prévia. A selfie nunca sai do
aparelho do visitante (o navegador envia só o vetor). A flag
`photoAlbum.selfieSearchEnabled` nasce `false` e é o kill switch imediato.
### Abertura (depois do aval jurídico/DPIA registrado)
### Abertura (ordem obrigatória)
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):
1. **Canal de remoção** no admin (`/admin` → Configurações → Álbum de fotos):
`https://` ou `mailto:` válido. Sem ele o álbum não publica e o lote de
aprovação recusa.
2. **Consentimentos** (textos versionados em `scripts/lib/faceConsentTexts.mjs`):
no serviço de manutenção do alvo, com o env file do stack:
```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
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e FACE_CONSENT_SEED_CONFIRM=1 \
teqo-1313-migrate pnpm seed:face-consents --apply
```
3. Indexe o acervo aprovado para os inscritos (canário primeiro):
(dry-run sem `--apply` mostra o estado; o guard exige `TEQO_ENV` casando o
banco exato.)
3. **Aprovar o acervo** (drafts → approved; `removed` nunca é tocado):
```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
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e ARCHIVE_PUBLISH_CONFIRM=1 \
teqo-1313-migrate pnpm archive:publish --apply
# canário: --limit 20
```
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.
Ao fim, buste a tag pública em um processo que a possui (o write do CLI não
revalida o servidor Next):
```bash
curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
```
4. **Indexar** (horas; o engine roda local no maintenance, sem endpoint externo):
```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 # canário
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 # lote completo
docker compose --profile maintenance run --rm \
-v /srv/face-reports:/app/data/face/reports \
teqo-1313-migrate pnpm faces:index --verify # read-only; sai 1 com pendências
```
O resultado no site aparece em até ~60 s (cache em memória do índice).
5. **Ligar** `Busca por selfie` no global Álbum de fotos (admin). Sem deploy;
desligar fecha a página (404), a API e a seção da home imediatamente.
### Remoção e re-consentimento
### Remoção e privacidade
- **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.
- **Sair do índice**: a própria pessoa usa “Minha presença” no fluxo (selfie
confere; os descritores correspondentes são apagados e o rosto deixa de ser
encontrado). O pedido de **remoção de foto** continua no canal do álbum.
- **Desaprovar/remover foto**: os descritores daquela foto são apagados pelos
hooks da collection (e o read aprovado nunca os mostra).
- **Rollback**: desligue a flag; a migration `add_archive_photo_face` é aditiva,
exceto o drop da `face_subject` (tabela vazia em produção).
### 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`).
`--apply` exige `TEQO_ENV` casando o banco exato e a flag de confirmação do
comando (`FACE_INDEX_CONFIRM`/`ARCHIVE_PUBLISH_CONFIRM`/`FACE_CONSENT_SEED_CONFIRM`),
mais as quatro `S3_*` quando a mídia for remota (indexação). Recibos JSON em
`data/face/reports/` e `data/archive/reports/` (nunca levam vetor nem bytes).
## C230-followup — importação do Instagram para a Central de Conteúdos

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>Design — C234 · Busca de fotos por selfie</title>
<title>Design — C234/C242 · Busca de fotos por selfie</title>
<style type="text/tailwindcss">
@theme inline {
--font-sans: Inter, Arimo, ui-sans-serif, system-ui, sans-serif;
@ -225,28 +225,29 @@
<header class="max-w-3xl">
<div class="flex flex-wrap gap-2">
<span class="rounded-full bg-[#e4102f] px-3 py-1 text-xs font-extrabold text-white"
>C234 · gate hi-fi</span
>C234 + C242 · 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
>Escopo B · 01 out 2026</span
>
</div>
<h1 class="display mt-4 text-3xl font-black tracking-[-.03em]">
Encontre você nas fotos — privado, explícito e sem placar de semelhança
</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
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.
Qualquer visitante pode partir do álbum, entender o uso de biometria, escolher a própria
selfie e receber apenas fotos públicas aprovadas em que aparece. A selfie permanece no
dispositivo e é possível sair do índice.
</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="font-extrabold text-[#184e92]">Delta de produto desta revisão · C242</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.
O índice deixa de depender de adesão prévia por pessoa: ele cobre os rostos do acervo
público aprovado, sem nomes ou vínculo com cadastro. O aviso público do índice aparece
antes da autorização de consulta; “Minha presença” permite retirar o próprio rosto a
qualquer momento. As cenas sem delta visual permanecem intactas.
</p>
</div>
</header>
@ -259,7 +260,7 @@
<section aria-labelledby="s1">
<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>
<span class="revision-badge">Revisada · C242 escopo B</span>
</div>
<h2 id="s1" class="display mt-1 text-xl font-black">
Entrada real na /fotos · flag ligada
@ -317,8 +318,8 @@
<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.
Use uma selfie sua. A imagem não sai deste dispositivo. A busca cobre o acervo
público aprovado, e você pode retirar seu rosto do índice.
</p>
</div>
</div>
@ -393,7 +394,8 @@
<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.
Sua selfie fica neste aparelho. A busca cobre o acervo público aprovado, e
você pode sair do índice.
</p>
</div>
</div>
@ -447,7 +449,7 @@
<section aria-labelledby="s2">
<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>
<span class="revision-badge">Revisada · C242 aviso + consulta</span>
</div>
<h2 id="s2" class="display mt-1 text-xl font-black">
Desktop ~1280 · aviso e consentimento na página própria, não em modal
@ -491,10 +493,30 @@
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.
Com sua autorização, usamos a sua selfie somente nesta consulta para procurar você
no índice biométrico anônimo do acervo público aprovado.
</p>
<div class="mt-5 rounded-xl border border-[#184e92]/20 bg-[#eef4fb] p-4">
<div class="flex items-start gap-3">
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#shield" /></svg>
<div>
<p class="text-sm font-extrabold text-[#143c70]">
Aviso público sobre o índice de fotos
</p>
<p class="mt-1 text-sm leading-6 text-[#365472]">
Rostos presentes nas fotos públicas aprovadas compõem um índice sem nomes e
sem vínculo com cadastros de pessoas. Ele serve apenas para localizar as
próprias fotos. Você pode retirar seu rosto do índice a qualquer momento em
“Minha presença”.
</p>
<a
href="#"
class="focus-ring mt-2 inline-flex min-h-11 items-center text-sm font-extrabold text-[#184e92] underline underline-offset-4"
>Ler o aviso completo do índice</a
>
</div>
</div>
</div>
<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>
@ -508,10 +530,9 @@
<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
<b>Você mantém o controle.</b><br /><span class="text-muted-foreground"
>Saia do índice quando quiser. O resultado não mostra nomes de terceiros nem
percentual de semelhança.</span
>
</p>
</div>
@ -522,8 +543,8 @@
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
>Li o aviso e autorizo o uso da minha selfie e da minha biometria somente nesta
consulta para procurar a mim mesmo no álbum.</span
></label
>
<p class="mt-3 text-xs leading-5 text-muted-foreground">
@ -542,6 +563,15 @@
</div>
</div>
</div>
<div
class="mt-4 max-w-3xl rounded-xl border border-[#184e92]/15 bg-[#f9faff] p-4 text-xs leading-5 text-muted-foreground"
>
<b class="text-foreground">Contrato de conteúdo:</b> o bloco azul é o aviso público
<code class="font-bold text-[#184e92]">busca-selfie-indice</code>, não um consentimento de
adesão ao índice. A caixa abaixo autoriza apenas o uso da própria selfie e biometria na
consulta atual (<code class="font-bold text-[#184e92]">busca-selfie-fotos</code>). Sem
qualquer um dos dois textos, vale a Cena 07.
</div>
</section>
<!-- CENA 03 -->
@ -799,7 +829,7 @@
<section aria-labelledby="s6">
<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>
<span class="revision-badge">Revisada · escopo B</span>
</div>
<h2 id="s6" class="display mt-1 text-xl font-black">
Mobile ~390 · vazio honesto, sem “parecidos”
@ -818,8 +848,8 @@
</div>
<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">
Esta busca funciona para quem autorizou participar do índice. Também pode não haver
uma foto sua entre os registros públicos aprovados.
Pode não haver uma foto sua entre os registros públicos aprovados, ou seu rosto pode
já ter sido retirado do índice.
</p>
<p class="mt-3 text-sm font-bold">
Não mostramos pessoas parecidas nem tentamos adivinhar quem você é.
@ -836,7 +866,7 @@
<section aria-labelledby="s7">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 07</p>
<h2 id="s7" class="display mt-1 text-xl font-black">
Fail-closed · consentimento não configurado, fluxo indisponível
Fail-closed · aviso ou consentimento não configurado, fluxo indisponível
</h2>
<div class="stage mt-4 max-w-[920px] rounded-2xl border border-border bg-white p-8 sm:p-12">
<div class="rounded-2xl border border-amber-300 bg-amber-50 px-6 py-10 text-center">
@ -852,8 +882,8 @@
A busca por selfie está indisponível
</h3>
<p class="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.
O aviso público do índice ou o consentimento da consulta não está configurado. Por
segurança, não podemos receber nem processar sua selfie agora.
</p>
<p class="mx-auto mt-3 max-w-lg text-sm font-bold text-amber-950">
Nenhuma imagem foi capturada ou enviada.
@ -869,10 +899,10 @@
<section aria-labelledby="s8">
<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>
<span class="revision-badge">Revisada · C242 saída do índice</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
Mobile ~390 · saída do índice a qualquer momento, com confirmação e sucesso
</h2>
<div class="mt-4 flex flex-wrap items-start gap-8">
<div class="stage w-[390px] overflow-hidden rounded-[30px] border border-border bg-white">
@ -885,10 +915,11 @@
<p class="text-xs font-black tracking-[.08em] text-[#184e92] uppercase">
Privacidade
</p>
<h3 class="display mt-1 text-2xl font-black">Você decide se quer aparecer</h3>
<h3 class="display mt-1 text-2xl font-black">Você controla sua presença no índice</h3>
<p class="mt-2 text-sm leading-6 text-muted-foreground">
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.
Seu rosto pode fazer parte do índice anônimo por aparecer no acervo aprovado. Para
retirá-lo, use uma selfie sua. Para retirar uma foto pública, fale com a equipe do
acervo.
</p>
<label class="mt-5 flex gap-3 rounded-xl border-2 border-[#184e92] bg-[#f9faff] p-4"
><span
@ -897,7 +928,8 @@
><span
><b class="text-sm">Sair do índice de busca</b
><small class="mt-1 block text-xs leading-5 text-muted-foreground"
>Seu rosto deixa de ser usado para localizar fotos por selfie.</small
>Apaga do índice as referências biométricas que correspondem ao seu
rosto.</small
></span
></label
>
@ -931,8 +963,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 reconhecer você neste aparelho e, se houver correspondência, retirar sua
presença do índice de busca.
Vamos processar sua selfie neste aparelho e, se houver correspondência, apagar do
índice as referências biométricas do seu rosto. A selfie não será enviada.
</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"
@ -944,6 +976,34 @@
</button>
</div>
</div>
<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">
<span class="w-[18px]"></span><span class="text-sm font-bold">Minha presença</span
><span class="w-[18px]"></span>
</header>
<div class="flex min-h-[635px] 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">Pronto — seu rosto saiu do índice</h3>
<p class="mt-3 max-w-[315px] text-sm leading-6 text-muted-foreground">
Apagamos as referências biométricas que correspondiam ao seu rosto. Ele deixa de ser
encontrado pela busca por selfie.
</p>
<p class="mt-3 max-w-[315px] text-sm font-bold">
As fotos públicas continuam no álbum. Para retirar uma foto específica, use o canal
da equipe responsável pelo acervo.
</p>
<a href="#" class="primary mt-7 w-full">Voltar ao álbum</a>
<a
href="#"
data-source="photoAlbum.removalChannelUrl"
class="focus-ring mt-4 inline-flex min-h-11 items-center text-sm font-extrabold text-[#184e92] underline underline-offset-4"
>Pedir remoção de uma foto</a
>
</div>
</div>
</div>
</section>
@ -1140,8 +1200,8 @@
</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.
Não encontramos no índice referências biométricas que correspondam ao seu rosto 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">

View file

@ -0,0 +1,126 @@
# Impl: C242 — Busca por selfie aberta a qualquer visitante (escopo B)
Status: aprovado (decisão do dono na sessão, 2026-10-01) — hard-stops humanos registrados (migração de schema, contrato público, Consent/LGPD)
Atualizado em: 2026-10-01
Issue: — (worktree `work/43`, sem claim)
Intenção: docs/plans/busca-selfie-escopo-b.md
Design UI (gate): docs/plans/busca-fotos-por-selfie-ui-design.html (estendido no C243; aqui só copy/estados)
Appetite restante: herdado (~2–3 dias eng + ops de publicação/indexação) — índice biométrico **anônimo** do acervo aprovado, sem enrollment e sem cadastro de pessoa.
## Leitura da intenção
- **Outcome:** qualquer visitante com rosto em foto aprovada se encontra; sem aviso/consentimento/flag, nada abre; sem score/nomes; opt-out e remoção funcionam; foto desaprovada sai na hora.
- **O que NÃO negociar:**
- Fail-closed de Consent/LGPD: sem as duas linhas de Consent com as chaves exatas, o endpoint recusa (503) e a página mostra o estado fechado.
- A selfie nunca sai do dispositivo (vetor-only), sem score, sem nome de terceiro, sem retenção.
- Resultado = interseção com `getApprovedArchivePhotoItems()` (draft/removida nunca).
- Kill switch `photoAlbum.selfieSearchEnabled` e `photoAlbum.published` valem no servidor.
- Sem vínculo com `Contact`/leadership; o índice não vira cadastro de pessoas.
- **O que reavaliar (achados do reconhecimento):**
- O índice A/C persiste **só os vínculos** `matchedPhotos` por subject; os descritores do acervo são transitórios (`faceSubjectPhotoIndex.ts:28-44`). B exige persistir descritores por foto — collection nova.
- `findFaceMatch` casa a consulta contra **subjects elegíveis** (`faceSearch.ts:101-128`); B casa a consulta contra **descritores do acervo**.
- O marker `faces.checkedKey` hoje resume modelo + revisões de subjects (`faceSubjectPhotoIndex.ts:111-120`); em B resume só o modelo.
- `deriveArchivePhotoCatalogIndex` já ignora writes do lote via `context.faceIndex` (`ArchivePhoto.ts:168-172`) — manter.
- Produção verificada 2026-10-01: `archive_photo` 0 aprovadas / 6.492 draft; `face_subject` 0; 0 consentimentos; flag default off; `photo_album` sem linha (defaults: published=true, selfie=false, sem canal de remoção).
## Abordagem recomendada
```mermaid
flowchart LR
subgraph OPS["Ops — CLI"]
PUB["pnpm archive:publish --apply\naprova drafts em lote"] --> APR[("archivePhoto\napproved")]
IDX["pnpm faces:index --apply"] --> DLD["downloadPrivateMediaToFile + sharp ≤1024px"]
DLD --> NODE["engine node-wasm (face-api)"]
NODE --> ROW[("archivePhotoFace\nphoto · vector 128d · model")]
APR --> IDX
CANAL["canal de remoção no global"] --> PUB
end
subgraph PUB2["Público"]
ALB["/fotos + seção da home (C243)"] --> ENC["/fotos/encontre\nflag + aviso + consentimento"]
ENC --> BRW["engine on-device · selfie NÃO sai"]
BRW -- "vetor 128d ≤4KB" --> API["POST /api/fotos/selfie"]
API --> CACHE["índice de descritores em memória (TTL 60s)"]
CACHE --> APR2["getApprovedArchivePhotoItems ∩ matches\nsem nomes · sem score · ≤60"]
API -- "leave-index" --> DEL["apaga descritores do rosto"]
UNAP["desaprovar/remover foto"] --> PURGE["hook apaga descritores da foto"]
end
```
**Opções consideradas (índice B):**
A) collection `archivePhotoFace` (1 linha/rosto, `json` 128 floats) + ranking em memória com cache curto; B) coluna json no próprio `archivePhoto`; C) pgvector/serviço externo.
**Recomendação:** **A** — segue o precedente C229 (`speechEmbedding` jsonb + ranking em memória), isola PII biométrica da ficha pública, permite apagar por rosto/foto e não exige extensão de banco (o Postgres do homeserver é vanilla; C229 rejeitou pgvector).
**Rejeitadas:** **B** porque carregaria ~6,5k documentos com vetores a cada scan e misturaria biometria na ficha do acervo; **C** porque troca imagem do Postgres/instala extensão por ganho marginal na escala atual (~dezenas de milhares de rostos).
### Componentes / mudanças
**Schema**
- **`src/collections/ArchivePhotoFace.ts`** (novo): slug `archivePhotoFace`, labels “Rosto no índice”/“Rostos no índice”, grupo `Comunicação`, `admin.hidden: true` (dado de máquina; sem UI), access `payloadAdminOnly` nas quatro operações. Campos: `photo` (relationship → `archivePhoto`, required, index), `model` (text, required, index), `vector` (json, hidden, readOnly), `detectedAt` (date, readOnly). Sem hooks.
- **`src/payload.config.ts`**: registrar a collection; remover `FaceSubject`.
- **`src/collections/FaceSubject.ts`**: **deletar**.
- **Migration `add_archive_photo_face`** (gerada + revisada à mão): cria `archive_photo_face` (+ FK para `archive_photo` com `ON DELETE CASCADE` e índice por `photo`), dropa `face_subject`, `face_subject_rels`, `enum_face_subject_status` (0 linhas em produção). Nunca editar migrations antigas.
- **`src/collections/ArchivePhoto.ts`**: hook novo `afterChange` que apaga descritores quando a foto deixa de ser `approved` (e `afterDelete` equivalente); o batch continua marcando `faces.checkedAt/checkedKey` com `context.faceIndex`.
**Contrato puro (`src/lib/faceSearch.ts`, editar)**
- Manter descritor/modelo/threshold/intents/view model/stub.
- Novo `findFaceDescriptorMatch({ vector, descriptors, model })` → melhores `photoIds` distintos sob o threshold (nunca devolve distância).
- Remover `findFaceMatch`/`faceSubjectIsEligible`/`FaceSearchSubject` (sem subjects).
**Índice (novo domínio `src/utilities/faceIndex/`, substitui `faceSubjects/`)**
- `faceDescriptorIndex.ts` (`server-only`): `listArchivePhotoFaceIndexQueue({ payload, refresh, limit })` (aprovadas + marker), `indexArchivePhotoFaces({ payload, item, indexKey, analyze })` — baixa/prepara/analisa e, em uma transação, **substitui** os descritores da foto e grava o marker `sha256(model)`. Falha por foto é resultado, nunca throw (estágios download/image/analyze/write).
- `faceDescriptorReads.ts` (`server-only`): `loadFaceDescriptorIndex(payload)` com cache em memória (TTL 60 s, `Float32Array` por descritor, cap defensivo documentado) e `deleteFaceDescriptorMatches({ payload, vector, model })` para o opt-out; leitura direta (sem cache) no delete para o opt-out valer na hora.
- Deletar `src/utilities/faceSubjects/*` e `scripts/enroll-face-subject.mjs`; remover `faces:enroll` do `package.json`; ajustar `scripts/copy-face-vision-assets.mjs` só se referenciar o CLI (não referencia).
**Endpoint (`src/app/(frontend)/api/fotos/selfie/route.ts`, editar)**
- Mesmas recusas (400 body, 403 origin, 404 flag/álbum, 429 rate, 503 consent) — muda o miolo: exige os dois Consents (`busca-selfie-fotos` consulta + `busca-selfie-indice` aviso); match contra `loadFaceDescriptorIndex`; `leave-index` apaga os descritores que casarem; resposta `getApprovedArchivePhotoItems ∩ photoIds` (≤60, `toFaceSearchPhotoView`).
- Página `/fotos/encontre`: passa a exigir/consumir também o aviso (renderiza o texto do aviso + texto do consentimento) e mantém o estado fechado sem qualquer um dos dois; `/fotos` mostra a entrada quando flag + aprovadas (e aviso/consentimento configurados).
**Copy/estados (porta do design estendido no C243)**
- `SelfieSearchEntry`: “a busca cobre quem autorizou participar do índice” → cobre o acervo aprovado, com saída do índice.
- `SelfieSearchFlow`: textos de consentimento/aviso e o estado de “Minha presença” (o vetor/descritores do rosto são apagados do índice).
**CLI de publicação (novo `scripts/publish-archive-photos.mjs`, guardas C231/C232)**
- Dry-run default; `--apply` exige `ARCHIVE_PUBLISH_CONFIRM=1`, `TEQO_ENV` casando o banco e as 4 `S3_*`; `--limit`, `--refresh`; nunca toca `removed`; exige `removalChannelUrl` válido (o guard da collection reforça); recibo JSON em `data/archive/reports/`; ao fim, instrui/deposita o bust da tag `archivePhotos` via `POST /api/revalidate` (writes fora do processo Next não revalidam sozinhos).
- `package.json`: script `archive:publish`.
**Testes**
- Unit: `faceSearch` (match por descritores, threshold estrito, sem score), parser do CLI de publicação, plan/CLI do índice.
- Int: `faceSearchApi` (busca sem subject, opt-out, flag/álbum/Consents, rate limit, 404/503) e `faceDescriptorIndex` (substituição por foto, purge em desaprovação/delete, marker).
- E2e: `frontendFotosSelfie` (semear descritores via fixture em vez de subject; fluxo completo com stub), seção da home (C243).
- Fixtures/helpers: `tests/helpers/faceIntFixtures.ts` reescrito para descritores; ownership de `archivePhotoFace`.
**Ops (runbook §C234 reescrito)**
1. Admin: criar os dois Consentimentos (textos da entrega) e o canal de remoção no global Álbum de fotos.
2. `pnpm archive:publish --apply` (6.492) + bust da tag; conferir `/fotos`.
3. `pnpm faces:index --apply` (horas; canário `--limit 20` antes) + `--verify`.
4. Ligar `Busca por selfie`; conferir `/fotos/encontre` e a seção da home.
Rollback: desligar a flag; migration do índice é aditiva exceto o drop do `face_subject` (vazio).
**Docs**
- `AGENTS-public.md` (contrato `/fotos/encontre` reescrito para escopo B), `docs/ops/teqo-1313-deploy.md` §C234, `docs/changelog/<data>-c240-c241.md`, planos atualizados (C234 ganha nota de superação).
## Riscos
- **Falso positivo do threshold** mostra foto de outra pessoa — mitigado por threshold conservador, canário e calibração no runbook; sem score na UI.
- **Custo do lote** (~6,5k detecções) — ops em background no homeserver; retomável pelo marker.
- **Descrição/volume do índice em memória** — `Float32Array`, TTL curto e cap documentado; degrada para vazio honesto se o índice não carregar.
- **Migração destrutiva** do `face_subject` (vazio em produção; staging/local com dados de teste podem perder linhas — aceitável e registrado).
## Checklist
- [ ] Collection + migration + purge hooks + types
- [ ] Domínio `faceIndex` (batch/reads/delete) + CLI `faces:index` novo + remoção do enrollment
- [ ] Endpoint + páginas fail-closed (consulta + aviso)
- [ ] CLI `archive:publish` + script
- [ ] Copy/estados do fluxo e da entrada `/fotos` (porta do design)
- [ ] Testes unit/int/e2e + fixtures
- [ ] Docs (AGENTS-public, runbook, changelog, planos)
- [ ] Ops: consentimentos, canal, lote, indexação, flag

View file

@ -0,0 +1,92 @@
# C242 — Busca por selfie aberta a qualquer visitante (escopo B)
Status: aprovado (decisão do dono na sessão, 2026-10-01)
Atualizado em: 2026-10-01
Issue: — (worktree `work/43`, sem claim)
Priority: P1
Impeccable: C — superfície pública de biometria (muda o escopo do índice facial do C234)
Design UI: docs/plans/busca-fotos-por-selfie-ui-design.html (estender no C243; esta fatia muda copy/estados do fluxo, não a gramática visual)
Appetite: ~2–3 dias eng + ops de publicação/indexação; um outcome verificável — **qualquer visitante** encontra no acervo público aprovado as fotos em que aparece, sem que a selfie saia do aparelho e sem score
Responsável: —
## Intenção
O C234 entregou a busca por selfie **fechada por padrão** e no escopo A/C: só quem foi inscrito pela assessoria (`pnpm faces:enroll`, consentimento assinado) entra no índice; um visitante comum nunca se encontra. O dono decidiu (2026-10-01), com aval jurídico declarado na sessão, **reabrir o escopo do gate (PR #1370) para B**: indexar os rostos presentes no acervo público aprovado, anonimamente (sem nome, sem vínculo com pessoa), para que **qualquer visitante** consiga se achar. A base de fotos também nunca foi publicada (6.492 drafts, 0 aprovadas) — sem isso não há o que buscar —, então a fatia inclui a publicação em lote do acervo e a indexação.
O que muda de conceito: o índice deixa de ser "adesão de pessoas" e passa a ser **índice biométrico anônimo do acervo aprovado**, com transparência pública (aviso), remoção a pedido (opt-out por selfie + canal de remoção de foto) e kill switch imediato. O enrollment por CLI e a collection `faceSubject` ficam obsoletos e são removidos (editar o dono, não criar gêmeo).
## Persona e fluxo
- **Persona / contexto:** visitante que foi a um evento com Solla, no celular, sem conta na campanha.
- **Job principal:** encontrar, entre as fotos públicas aprovadas, as que eu apareço.
- **Fluxo desejado:** abre `/fotos` (ou a seção da home) → toca "Encontre você nas fotos" → lê o aviso (biometria/LGPD + como sair do índice) e consente com o uso da própria selfie → tira/envia uma selfie → o reconhecimento roda no dispositivo (a selfie não sobe) → vê só as fotos aprovadas em que aparece, sem nome de terceiros e sem score → a qualquer momento pede para sair do índice (selfie de novo → o rosto deixa de ser encontrado) e/ou despublica uma foto pelo canal de remoção.
- **Anti-goals de produto:** não é vigilância nem CRM de rostos; não nomeia nem identifica terceiros; não cruza com `Contact`/leadership; não oferece busca por outra pessoa; não abre sem aviso/consentimento configurados; não retém a selfie.
## Objetivo e aceite
- Com o aviso e o consentimento de consulta configurados e a flag ligada, **qualquer visitante** que apareça em foto aprovada recebe a lista dessas fotos.
- Sem aviso/consentimento configurado (ou flag desligada), o fluxo recusa com linguagem clara — nunca meio aberto, nem na UI nem no servidor.
- Nenhum rastro da selfie no servidor: o browser envia só o vetor 128-d; nenhuma imagem, nenhum score, nenhum nome de terceiro.
- Nenhuma foto draft/removida no resultado; desaprovar uma foto a tira da busca imediatamente (por construção: o resultado cruza o índice com o read aprovado do C233, e os descritores da foto são apagados na desaprovação/remoção).
- Opt-out funciona: "Minha presença" com a selfie apaga os descritores daquele rosto do índice e ele deixa de ser encontrado; o pedido de remoção de foto continua no canal do álbum.
- O índice cobre só o acervo **aprovado** e só o modelo vigente; trocar o modelo invalida a revisão e o próximo lote reprocessa.
- Antiabuso: rate limit anônimo (20/10 min por IP hasheado), same-origin, body ≤4 KB.
- **Guardrails:** Consent/LGPD fail-closed (aviso + consentimento de consulta são o gate); sem cruzamento com `Contact`/leadership; a biometria não vira cadastro de pessoa.
## Dados (intenção)
- **Vou apresentar dados?** Não — o resultado é "quais fotos", nunca "quão parecido". Score/percentual é anti-goal explícito do dono.
- **Decisões desbloqueadas:** N/A — a escolha do visitante ("essa foto é minha, quero guardar/compartilhar") é qualitativa.
- **Forma:** N/A — **sem score/percentual** de semelhança em nenhum estado; a única saída é a lista de fotos.
## Dados da decisão (literais)
- **Escopo do índice — decisão do dono (2026-10-01): B.** Indexa os rostos do acervo público aprovado, anonimamente; a resposta é só sobre a própria pessoa consultante. O DPIA/aval jurídico foi **declarado aprovado pelo dono na sessão** ("o jurídico aprovou; qualquer coisa ajeitamos ou tiramos do ar depois"); o fail-closed de configuração (aviso + consentimento) permanece no código.
- **Textos:** os dois Consentimentos (`busca-selfie-fotos` de consulta e `busca-selfie-indice`, reclassificado como **aviso público do índice**) são redigidos nesta entrega e configurados no admin antes da abertura; nada abre sem eles.
- **Selfie nunca sai do dispositivo:** o browser roda o engine e envia só o vetor; sem upload, sem galeria, sem retenção.
- **Resposta mínima:** só fotos aprovadas em que a pessoa consultante aparece; nunca "outras pessoas parecidas", nunca nome de terceiro, nunca score.
- **Remoção:** opt-out por selfie apaga os descritores do rosto no índice; foto específica sai pelo canal de remoção do álbum (`photoAlbum.removalChannelUrl`, obrigatório).
- **Publicação do acervo:** as 6.492 fotos draft são aprovadas em lote (decisão do dono, 2026-10-01), com guardas de ops; `removed` nunca é reaprovada por lote.
- **Enrollment A/C morre:** `faceSubject`, `pnpm faces:enroll` e a adesão por pessoa são removidos; a chave `busca-selfie-indice` passa a nomear o aviso do índice.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/collections/ArchivePhoto.ts` (grupo `faces` existente), collection nova do índice (`archivePhotoFace`), `src/utilities/faceSubjects/*` (reescrito como domínio `faceIndex`), `src/lib/faceSearch.ts` (match por descritor do acervo), `src/app/(frontend)/api/fotos/selfie/route.ts`, `scripts/index-archive-faces.mjs` (+ novo CLI de publicação), migration.
- **Precedente a olhar:** C229 (`speechEmbeddingIndex.ts`/`speechEmbedding` — índice vetorial em jsonb com ranking em memória), `archivePhotoReads.ts` (read público aprovado + tag), `withPayloadTransaction`, guardas de CLI do C231/C232, `revalidateRequest` (`/api/revalidate` para writes fora do processo Next).
- **Risco de acoplamento:** a busca hoje resolve `faceSubject`; remover a collection exige migração destrutiva (tabela vazia em produção — verificado: 0 linhas) e atualização de testes/fixtures; o endpoint e o e2e mudam de "seed de subject" para "seed de descritores".
## Dependências
- Duras: C231/C232/C233 (acervo, catalogação, álbum público) — entregues.
- Duras de produto: aval jurídico/DPIA — **declarado aprovado pelo dono na sessão de 2026-10-01**; registrar no changelog.
- Soft: C243 (seção na home) consome o estado "busca aberta" daqui.
## Fora de escopo
- Identificação/nomeação de terceiros; diretório de rostos; busca por outra pessoa; indexação de vídeo; API pública de reconhecimento.
- Retenção de selfie/imagem; score; galeria.
- Curadoria foto a foto do acervo (o lote publica tudo, com canal de remoção).
## Rabbit holes de produto
- **"Só completar" indexando vídeo/gravações.** **Corte:** só fotos aprovadas do acervo.
- **"Só completar" nomeando quem aparece.** **Corte:** nunca nome inferido de rosto; `catalog.people` continua vindo só do catálogo curado e por texto.
- **"Só completar" com score/"match 87%".** **Corte:** sem número em nenhum estado.
- **"Só completar" guardando a selfie.** **Corte:** vetor-only, sem retenção.
## Questões em aberto (produto)
- **Calibração do threshold (0.45) para índice anônimo:** risco assimétrico muda (falso positivo mostra foto de outra pessoa). **Recomendação:** manter 0.45 no v1 e calibrar com o canário/amostra no runbook; número nunca aparece na UI.
- **Quem atende a fila de remoção de foto:** comunicação/assessoria, como no C233 (canal de remoção já é dela).
## Referências
- `docs/plans/busca-fotos-por-selfie.md` (C234, escopo A/C — decisão superada por este item)
- `docs/plans/busca-fotos-por-selfie-impl.md` (dono técnico a editar)
- `docs/plans/album-fotos-publico-busca.md` (C233 — álbum e canal de remoção)
- `docs/ops/teqo-1313-deploy.md` §C234 (runbook a reescrever)
- `docs/changelog/2026-09-27-c234.md` (entrega anterior)
## Self-score (shaping)
5/5 — (1) fatia = um outcome verificável (qualquer visitante se acha no acervo aprovado; sem configuração, recusa); (2) appetite comporta schema + CLI + endpoint + remoção + ops, sem pesquisa nova; (3) persona, job e aceite verificáveis; (4) direção no codebase com precedentes nomeados; (5) decisões duras (escopo/legal) tomadas pelo dono e registradas.

View file

@ -0,0 +1,35 @@
# Impl: C243 — Seção da busca por selfie na homepage pública
Status: aprovado (decisão do dono na sessão, 2026-10-01)
Atualizado em: 2026-10-01
Issue: — (worktree `work/43`, sem claim)
Intenção: docs/plans/busca-selfie-secao-home.md
Design UI (gate): docs/plans/busca-selfie-secao-home-ui-design.html
Appetite restante: herdado (~0,5–1 dia eng) — uma seção condicional na home, sem engine.
## Leitura da intenção
- **Outcome:** visitante descobre a busca na home e chega a `/fotos/encontre`; fechada, a seção não aparece (sem CTA morto).
- **O que NÃO negociar:** sem captura/engine na home; sem promessa de "todas as fotos"/score; mobile-first; nada de fotos do acervo renderizadas na home; gate só com reads cacheados.
## Abordagem recomendada
**Opções consideradas (gate):** A) gating por `photoAlbum` cacheado (published + flag) + `hasPublishedArchivePhotos()` (também cacheado); B) leitura live dos Consents para o gate (mais exato, quebra a estaticidade/ISR da home).
**Recomendação:** **A** — a home mantém os reads cacheados; flag desligada já esconde a seção, e a combinação flag ligada + Consent ausente (misconfiguração) cai no estado fechado honesto da página de destino, nunca em 404 (a página `/fotos/encontre` existe nesse caso).
**Rejeitadas:** **B** porque uma leitura live de Consent por request transformaria a homepage (hoje estática com ISR por tags) em dinâmica sem ganho de segurança material — o servidor da busca é o gate real.
### Componentes / mudanças
- **`src/components/fotos/SelfieSearchHomeSection.tsx`** (novo): porta classe-a-classe de `docs/plans/busca-selfie-secao-home-ui-design.html`; título, explicação curta (selfie não sai do aparelho; acervo aprovado; saída do índice) e CTAs (`Encontre você nas fotos` → `/fotos/encontre`; link secundário `Ver o álbum` → `/fotos`).
- **`src/app/(frontend)/(home)/page.tsx`**: somar `photoAlbum` (via `getCachedGlobal`) ao `Promise.all`; renderizar a seção quando `published !== false && selfieSearchEnabled === true && showFotos` (mesmo `showFotos` já lido de `hasPublishedArchivePhotos`).
- **`src/components/fotos/SelfieSearchEntry.tsx`** (C242, ajuste de copy): atualizar o texto do escopo B quando o design estendido definir os literais.
- **Testes:** e2e da home (seção ausente com flag off/álbum fechado; presente com flag on + fotos aprovadas; CTA leva à página da busca) no spec existente de fotos/selfie ou no de frontend, seguindo o manifest curado; unit de gating se extrair predicado puro.
- **Docs:** changelog conjunto C242/C243; `AGENTS-public.md` ganha a seção.
## Checklist
- [ ] Design hi-fi aprovado (agente `designer`) gravado no disco
- [ ] Componente + composição na home + gating
- [ ] Copy da entrada `/fotos` alinhada ao escopo B
- [ ] Testes (unit/e2e) e manifest
- [ ] Docs/changelog

View file

@ -0,0 +1,481 @@
<!doctype html>
<html lang="pt-BR" data-theme="campaign-site">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#fff8f2" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>Design — C243 · Busca por selfie na homepage</title>
<style type="text/tailwindcss">
@theme inline {
--font-sans: Inter, Arimo, ui-sans-serif, system-ui, sans-serif;
}
[data-theme='campaign-site'] {
--pt-red: #a21c1c;
--pt-red-dark: #47130e;
--pt-yellow: #ffe607;
--campaign-cream: #fff8f2;
--campaign-ink: #000000;
--campaign-muted: #6c615e;
--campaign-surface: #f9faff;
--campaign-band: #ebe9e9;
--campaign-line: rgb(0 0 0 / 12%);
--campaign-content-inset: clamp(20px, calc(25.36vw - 79.64px), 180px);
}
* {
box-sizing: border-box;
}
body {
font-family: Inter, Arimo, ui-sans-serif, system-ui, sans-serif;
}
.display {
font-family: 'Exo 2', Inter, ui-sans-serif, system-ui, sans-serif;
}
.campaign-section-eyebrow {
font-size: clamp(10px, calc(0.32vw + 8.75px), 12px);
line-height: 1.25;
}
.campaign-section-title {
font-size: clamp(28px, calc(1.15vw + 23px), 38px);
line-height: 1.02;
}
.campaign-section-copy {
font-size: clamp(15px, calc(0.32vw + 13.75px), 17px);
line-height: 1.55;
}
.stage {
box-shadow: 0 24px 80px rgb(32 27 26 / 15%);
}
.archive-focus:focus-visible {
outline: 3px solid var(--pt-yellow);
outline-offset: 3px;
box-shadow: 0 0 0 6px rgb(24 78 146 / 72%);
}
.primary-action {
display: inline-flex;
min-height: 44px;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 10px;
background: var(--pt-red);
padding: 10px 16px;
color: white;
font-family: 'Exo 2', Inter, ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
font-weight: 800;
text-decoration: none;
box-shadow: 0 4px 0 #7f1616;
transition:
transform 150ms ease-out,
box-shadow 150ms ease-out;
}
.primary-action:hover {
transform: translateY(-2px);
}
.primary-action:active {
transform: translateY(2px);
box-shadow: 0 2px 0 #7f1616;
}
.secondary-link {
display: inline-flex;
min-height: 44px;
align-items: center;
gap: 6px;
border-radius: 4px;
color: var(--pt-red);
font-size: 14px;
font-weight: 800;
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-underline-offset: 4px;
}
.secondary-link:hover {
color: #741414;
text-decoration-thickness: 2px;
}
a {
touch-action: manipulation;
-webkit-tap-highlight-color: rgb(162 28 28 / 18%);
}
.assurance-mark {
background:
radial-gradient(circle at 76% 18%, rgb(255 230 7 / 66%) 0 8%, transparent 8.5%),
linear-gradient(145deg, #f9faff 0%, #eef4fb 100%);
}
@media (prefers-reduced-motion: reduce) {
.primary-action {
transition: none;
}
.primary-action:hover,
.primary-action:active {
transform: none;
}
}
@media (min-width: 64rem) {
[data-theme='campaign-site'] {
--campaign-content-inset: max(180px, calc((100vw - 1152px) / 2 + 32px));
}
}
</style>
</head>
<body class="min-h-screen bg-[#efeeec] text-[#201b1a] antialiased">
<svg class="absolute size-0 overflow-hidden" aria-hidden="true">
<symbol id="spark" viewBox="0 0 24 24">
<path d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z" />
<path d="m19 14-.9 2.1L16 17l2.1.9L19 20l.9-2.1L22 17l-2.1-.9L19 14Z" />
</symbol>
<symbol id="device" viewBox="0 0 24 24">
<rect x="5" y="2" width="14" height="20" rx="2" />
<path d="M9 18h6" />
</symbol>
<symbol id="images" viewBox="0 0 24 24">
<path d="m16 5 3 3M4 9V5a2 2 0 0 1 2-2h9l5 5v10a2 2 0 0 1-2 2h-4" />
<rect x="2" y="11" width="12" height="10" rx="2" />
<circle cx="6" cy="15" r="1" />
<path d="m4 19 3-3 5 5" />
</symbol>
<symbol id="shield" viewBox="0 0 24 24">
<path d="M20 13c0 5-3.5 7.5-8 9-4.5-1.5-8-4-8-9V5l8-3 8 3v8Z" />
<path d="m9 12 2 2 4-4" />
</symbol>
<symbol id="arrow" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
<symbol id="check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6" /></symbol>
</svg>
<main class="mx-auto flex max-w-[1480px] flex-col gap-14 px-4 py-10 md:px-8">
<header class="max-w-4xl">
<div class="flex flex-wrap gap-2">
<span class="rounded-full bg-[#a21c1c] px-3 py-1 text-xs font-extrabold text-white"
>C243 · gate hi-fi</span
>
<span class="rounded-full border border-black/10 bg-white px-3 py-1 text-xs font-bold"
>Homepage pública · aberta</span
>
<span
class="rounded-full border border-[#184e92]/20 bg-[#eef4fb] px-3 py-1 text-xs font-extrabold text-[#184e92]"
>Sem selfie, engine, fotos ou contadores na home</span
>
</div>
<h1 class="display mt-4 text-3xl font-black tracking-[-0.03em]">
A home apresenta a busca; a experiência sensível começa só em /fotos/encontre
</h1>
<p class="mt-2 max-w-3xl text-sm leading-6 text-[#6c615e]">
Seção condicional, mobile-first, com uma ação dominante. Ela herda a gramática de bandas,
inset e hierarquia da home e usa o azul do álbum apenas para explicar proteção e controle.
</p>
</header>
<section aria-labelledby="desktop-title">
<p class="text-xs font-extrabold tracking-[0.1em] text-[#a21c1c] uppercase">
Cena 01 · 1280
</p>
<h2 id="desktop-title" class="display mt-1 text-xl font-black">
Entre a Central de Conteúdos e a newsletter
</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-[#6c615e]">
A banda creme interrompe o catálogo branco sem competir com a newsletter cinza. O texto
explica o suficiente para uma decisão informada; o aviso completo e a autorização ficam na
página da busca.
</p>
<div class="stage mt-4 overflow-x-auto rounded-2xl border border-black/10 bg-white">
<div class="min-w-[1280px] bg-white">
<div
aria-hidden="true"
class="flex h-20 items-center justify-center border-b border-black/10 bg-white text-xs font-bold tracking-[0.08em] text-black/45 uppercase"
>
fim da seção · Central de Conteúdos
</div>
<!-- Port classe-a-classe: section border-b bg-(--campaign-cream); container
max-w-[1160px] px-5 sm:px-8 lg:px-10 py-12 lg:py-16. -->
<section
aria-labelledby="selfie-home-desktop-title"
class="relative overflow-hidden border-b border-(--campaign-line) bg-(--campaign-cream)"
>
<div
aria-hidden="true"
class="pointer-events-none absolute -top-24 -right-20 size-64 rounded-full bg-(--pt-yellow)/45"
></div>
<div
aria-hidden="true"
class="pointer-events-none absolute -bottom-32 left-[36%] size-64 rounded-full bg-(--pt-red)/8"
></div>
<div
class="relative mx-auto grid w-full max-w-[1160px] grid-cols-[minmax(0,1.1fr)_minmax(360px,.9fr)] items-center gap-16 px-10 py-16"
>
<div class="max-w-[620px]">
<p
class="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase"
>
Fotos da nossa caminhada
</p>
<h2
id="selfie-home-desktop-title"
class="campaign-section-title display m-0 mt-2 max-w-[560px] border-0 p-0 font-black tracking-[-0.03em] text-balance"
>
Encontre você nas fotos
</h2>
<p class="campaign-section-copy mt-4 max-w-[600px] text-(--campaign-muted)">
Use uma selfie sua na página da busca para procurar, entre as fotos públicas
aprovadas, aquelas em que você aparece.
</p>
<div class="mt-5 flex max-w-[610px] items-start gap-3 text-sm leading-6">
<div
class="mt-0.5 grid size-9 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white"
>
<svg aria-hidden="true" class="size-4 fill-none stroke-current stroke-2">
<use href="#shield" />
</svg>
</div>
<p class="m-0 text-[#365472]">
<b class="text-[#143c70]">A selfie não sai do seu aparelho.</b> A busca cobre
o índice anônimo do acervo aprovado, e você pode retirar seu rosto quando
quiser.
</p>
</div>
<div class="mt-7 flex flex-wrap items-center gap-x-6 gap-y-2">
<a href="/fotos/encontre" class="primary-action archive-focus">
Encontrar minhas fotos
<svg aria-hidden="true" class="size-4 fill-none stroke-current stroke-2">
<use href="#arrow" />
</svg>
</a>
<a href="/fotos" class="secondary-link archive-focus">
Ver o álbum
<svg aria-hidden="true" class="size-4 fill-none stroke-current stroke-2">
<use href="#arrow" />
</svg>
</a>
</div>
</div>
<div
class="assurance-mark relative overflow-hidden rounded-2xl border border-[#184e92]/15 p-6"
>
<div
aria-hidden="true"
class="absolute top-5 right-5 grid size-12 place-items-center rounded-full bg-white text-[#184e92] shadow-[0_8px_28px_rgb(24_78_146/12%)]"
>
<svg aria-hidden="true" class="size-6 fill-none stroke-current stroke-2">
<use href="#spark" />
</svg>
</div>
<p class="display max-w-[250px] text-xl leading-tight font-black text-[#143c70]">
Sua imagem fica com você. O controle também.
</p>
<ul class="mt-7 grid list-none gap-0 p-0">
<li class="flex items-center gap-3 border-t border-[#184e92]/12 py-4">
<svg
aria-hidden="true"
class="size-5 shrink-0 fill-none stroke-[#184e92] stroke-2"
>
<use href="#device" />
</svg>
<span class="text-sm font-bold text-[#143c70]"
>Processamento no aparelho</span
>
<svg
aria-hidden="true"
class="ml-auto size-4 fill-none stroke-[#184e92] stroke-2"
>
<use href="#check" />
</svg>
</li>
<li class="flex items-center gap-3 border-t border-[#184e92]/12 py-4">
<svg
aria-hidden="true"
class="size-5 shrink-0 fill-none stroke-[#184e92] stroke-2"
>
<use href="#images" />
</svg>
<span class="text-sm font-bold text-[#143c70]"
>Só o acervo público aprovado</span
>
<svg
aria-hidden="true"
class="ml-auto size-4 fill-none stroke-[#184e92] stroke-2"
>
<use href="#check" />
</svg>
</li>
<li class="flex items-center gap-3 border-y border-[#184e92]/12 py-4">
<svg
aria-hidden="true"
class="size-5 shrink-0 fill-none stroke-[#184e92] stroke-2"
>
<use href="#shield" />
</svg>
<span class="text-sm font-bold text-[#143c70]"
>Saída do índice a qualquer momento</span
>
<svg
aria-hidden="true"
class="ml-auto size-4 fill-none stroke-[#184e92] stroke-2"
>
<use href="#check" />
</svg>
</li>
</ul>
</div>
</div>
</section>
<div class="border-b border-black/10 bg-(--campaign-band) px-10 py-10 text-center">
<p
class="campaign-section-eyebrow font-black tracking-[0.1em] text-(--pt-red) uppercase"
>
Apoie e acompanhe
</p>
<p class="display mt-1 text-2xl font-black">Receba as novidades da campanha</p>
</div>
</div>
</div>
</section>
<section aria-labelledby="mobile-title">
<p class="text-xs font-extrabold tracking-[0.1em] text-[#a21c1c] uppercase">
Cena 02 · 390
</p>
<h2 id="mobile-title" class="display mt-1 text-xl font-black">
Mobile-first · uma ação dominante, sem simular captura
</h2>
<div
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-black/10 bg-white"
>
<div
aria-hidden="true"
class="flex h-16 items-center justify-center border-b border-black/10 bg-white text-[10px] font-bold tracking-[0.08em] text-black/40 uppercase"
>
fim da Central de Conteúdos
</div>
<section
aria-labelledby="selfie-home-mobile-title"
class="relative overflow-hidden border-b border-(--campaign-line) bg-(--campaign-cream)"
>
<div
aria-hidden="true"
class="pointer-events-none absolute -top-14 -right-14 size-40 rounded-full bg-(--pt-yellow)/45"
></div>
<div class="relative px-5 py-12">
<p
class="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase"
>
Fotos da nossa caminhada
</p>
<h2
id="selfie-home-mobile-title"
class="display m-0 mt-2 max-w-[320px] border-0 p-0 text-[30px] leading-[1.02] font-black tracking-[-0.03em] text-balance"
>
Encontre você nas fotos
</h2>
<p class="mt-4 text-[15px] leading-6 text-(--campaign-muted)">
Use uma selfie sua na página da busca para procurar as fotos públicas aprovadas em
que você aparece.
</p>
<div class="assurance-mark mt-6 rounded-2xl border border-[#184e92]/15 p-4">
<div class="flex items-start gap-3">
<div
class="grid size-10 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white"
>
<svg aria-hidden="true" class="size-5 fill-none stroke-current stroke-2">
<use href="#shield" />
</svg>
</div>
<div>
<p class="text-sm font-extrabold text-[#143c70]">Sua selfie fica no aparelho</p>
<p class="mt-1 text-xs leading-5 text-[#365472]">
A selfie não sai deste aparelho. A busca cobre o acervo aprovado, e você pode
retirar seu rosto do índice quando quiser.
</p>
</div>
</div>
</div>
<a href="/fotos/encontre" class="primary-action archive-focus mt-6 w-full">
Encontrar minhas fotos
<svg aria-hidden="true" class="size-4 fill-none stroke-current stroke-2">
<use href="#arrow" />
</svg>
</a>
<a href="/fotos" class="secondary-link archive-focus mt-2 w-full justify-center">
Ver o álbum
<svg aria-hidden="true" class="size-4 fill-none stroke-current stroke-2">
<use href="#arrow" />
</svg>
</a>
</div>
</section>
<div class="bg-(--campaign-band) px-5 py-9 text-center">
<p
class="campaign-section-eyebrow font-black tracking-[0.1em] text-(--pt-red) uppercase"
>
Apoie e acompanhe
</p>
<p class="display mt-1 text-[22px] leading-tight font-black">
Receba as novidades da campanha
</p>
</div>
</div>
</section>
<section
aria-labelledby="rules-title"
class="max-w-4xl rounded-2xl border border-black/10 bg-white p-6"
>
<p class="text-xs font-extrabold tracking-[0.1em] text-[#a21c1c] uppercase">
Contrato do port
</p>
<h2 id="rules-title" class="display mt-1 text-xl font-black">Estados e acessibilidade</h2>
<ul class="mt-4 grid list-none gap-3 p-0 text-sm leading-6 text-[#6c615e] sm:grid-cols-2">
<li class="rounded-xl bg-[#f9faff] p-4">
<b class="text-[#201b1a]">Fechada:</b> a seção inteira não é renderizada quando a flag,
o álbum publicado ou as fotos aprovadas não liberam a busca. Não há placeholder, aviso
nem CTA morto; Central de Conteúdos e newsletter se encostam pelo ritmo normal.
</li>
<li class="rounded-xl bg-[#f9faff] p-4">
<b class="text-[#201b1a]">Hierarquia:</b> o título da seção é <code>h2</code>; há um
único CTA preenchido. “Ver o álbum” permanece secundário, textual e com alvo mínimo de
44 px.
</li>
<li class="rounded-xl bg-[#f9faff] p-4">
<b class="text-[#201b1a]">Foco:</b> ambos os links usam o foco amarelo de 3 px do álbum,
com offset de 3 px e separação azul externa para permanecer visível sobre creme,
vermelho e branco.
</li>
<li class="rounded-xl bg-[#f9faff] p-4">
<b class="text-[#201b1a]">Movimento:</b> só o CTA reage ao hover/press; em
<code>prefers-reduced-motion: reduce</code>, transição e deslocamento são removidos.
</li>
</ul>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,76 @@
# C243 — Seção da busca por selfie na homepage pública
Status: aprovado (decisão do dono na sessão, 2026-10-01)
Atualizado em: 2026-10-01
Issue: — (worktree `work/43`, sem claim)
Priority: P2
Impeccable: C — seção nova na homepage pública (descoberta da busca por selfie)
Design UI: docs/plans/busca-selfie-secao-home-ui-design.html
Appetite: ~0,5–1 dia eng — **uma seção da home que apresenta e leva à busca por selfie**, sem duplicar a superfície do álbum
Responsável: —
## Intenção
Com o C242 abrindo a busca por selfie a qualquer visitante, a homepage pública passa a ter uma porta para ela. Hoje a busca só é descoberta dentro de `/fotos` (banda da cena 01); o dono pediu (2026-10-01) **seção própria na homepage**, que aparece quando a busca está aberta e leva direto a `/fotos/encontre`.
A seção é descoberta, não uma segunda busca: nada de captura de selfie na home (o engine pesado roda só na página da busca, como hoje). Copy e visual seguem a gramática das seções da home e o aviso de biometria/LGPD aparece de forma honesta.
## Persona e fluxo
- **Persona / contexto:** visitante na home da campanha, no celular, que viu Solla num evento e não sabe que existe um álbum pesquisável.
- **Job principal:** saber que dá para me encontrar nas fotos do mandato e chegar lá.
- **Fluxo desejado:** rola a home → encontra a seção "Encontre você nas fotos" com explicação curta (selfie não sai do aparelho; acervo aprovado; dá para sair do índice) → toca o CTA → chega em `/fotos/encontre`.
- **Anti-goals de produto:** não captura selfie na home; não lista pessoas; não promete "todas as fotos"; não vira carrossel de fotos com PII; não aparece fechada/quebrada.
## Objetivo e aceite
- A seção aparece na home **somente quando a busca está aberta** (flag `selfieSearchEnabled`, álbum publicado e fotos aprovadas) — fail-closed: nenhum CTA morto/404 enquanto fechada.
- O CTA leva a `/fotos/encontre`; a seção tem título, explicação curta e um link secundário para o álbum (`/fotos`).
- Mobile-first (390) e desktop (1280) fiéis ao design hi-fi aprovado; acessibilidade: heading na hierarquia da página, foco visível, contraste, `prefers-reduced-motion`.
- Nenhum texto que prometa identificação de terceiros ou score; o aviso de biometria é o mesmo contrato do C242.
## Dados (intenção)
- **Vou apresentar dados?** Não.
- **Forma:** N/A — sem números, sem contadores, sem "X fotos" (o acervo muda).
## Dados da decisão (literais)
- **Comportamento quando fechada — decisão do dono (2026-10-01):** "aparece quando aberta, mas já é pra abrir" → a seção é renderizada condicionada ao estado aberto e a abertura é feita na mesma entrega (ops do C242).
- **Sem captura na home:** o engine de face não é carregado na homepage (peso ~7 MB de modelos + wasm); a home só apresenta e linka.
- **Posição:** a definir no design hi-fi, entre as seções de conteúdo existentes (recomendação da engenharia: depois de `ContentPieceHomeSection`, antes da newsletter), sem quebrar o ritmo/altura das seções atuais.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/app/(frontend)/(home)/page.tsx` (composição + gating), componente novo em `src/components/fotos/`, classes/tokens `campaign-section-*` de `styles.css` e os padrões de seção da home (`JingleHomeSection`, `ContentPieceHomeSection`, `CampaignCardsSection`).
- **Precedente a olhar:** `SelfieSearchEntry.tsx` (banda do C234 em `/fotos`), `ContentPieceHomeSection.tsx` (seção condicional), `hasPublishedArchivePhotos` (flag cacheada) + `getCachedGlobal('photoAlbum')` (kill switches com tag própria).
- **Risco de acoplamento:** a home usa reads cacheados (ISR/tag); a seção não pode introduzir leitura live de Consent (perda de estaticidade). Gatear por flag global + fotos aprovadas (ambos cacheados e revalidados no admin); misconfiguração de Consent cai no estado fechado da página de destino, nunca em 404.
## Dependências
- Duras: C242 (busca aberta a qualquer visitante) — a seção só faz sentido com ela.
- Soft: nenhuma.
## Fora de escopo
- Captura/engine na home; carrossel de fotos; contadores; personalização por visitante.
- Mudar a `/fotos` ou o fluxo de `/fotos/encontre` além da copy/porta do C242.
## Rabbit holes de produto
- **"Só completar" com prévia de fotos na home.** **Corte:** a home não renderiza fotos do acervo (PII + peso); só a banda de convite.
- **"Só completar" com captura inline.** **Corte:** engine só em `/fotos/encontre`.
## Questões em aberto (produto)
- Nenhuma bloqueante; posição final e composição ficam no design hi-fi (gate visual).
## Referências
- `docs/plans/busca-fotos-por-selfie.md` (C234 — artefato de design a estender)
- `docs/plans/busca-selfie-escopo-b.md` (C242 — dono do estado "aberta")
- `src/components/fotos/SelfieSearchEntry.tsx` · `src/app/(frontend)/(home)/page.tsx`
## Self-score (shaping)
5/5 — (1) fatia = um outcome verificável (visitante descobre e chega à busca quando aberta; fechada não aparece); (2) appetite pequeno comporta design + componente + testes; (3) persona/job/aceite verificáveis; (4) direção no codebase com precedentes; (5) decisões duras (comportamento e captura) tomadas e registradas.

View file

@ -39,8 +39,9 @@
"content:instagram:withdraw": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/withdraw-instagram-content.mjs",
"content:instagram:prune": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/prune-instagram-content.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",
"archive:publish": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/publish-archive-photos.mjs",
"seed:face-consents": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-consents.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",

View file

@ -1,224 +0,0 @@
/**
* 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

@ -1,11 +1,9 @@
/**
* C234 — indexes the approved photo archive for the selfie search: each photo
* C242 — 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.
* face-api engine the browser runs; the descriptors of every detected face are
* PERSISTED as anonymous rows in `archivePhotoFace` (scope B, decision of
* 2026-10-01: any visitor can find themselves; no identity, no score).
*
* Modes:
* pnpm faces:index plan/dry-run (default) — the queue; no engine, no write
@ -13,7 +11,7 @@
* 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)
* pnpm faces:index --verify read-only inventory (indexed/stale/descriptors)
*
* Options: --limit <n> (canary), --refresh (ignores the marker), --out <dir>
* (receipts, default `data/face`), --help.
@ -25,7 +23,6 @@ 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 {
@ -63,14 +60,14 @@ 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.
os rostos com os mesmos modelos do navegador e grava os descritores como linhas
anônimas (foto + vetor + modelo) em archivePhotoFace. Não há nome nem vínculo
com pessoa; a busca responde só com fotos.
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)
--verify inventário read-only (indexadas/desatualizadas/rostos)
Opções:
--limit <n> --apply: processa no máximo n fotos (canário)
@ -130,24 +127,22 @@ async function main() {
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')
await import('../src/utilities/faceIndex/faceDescriptorIndex.ts')
const payload = await getPayload({ config })
const startedAt = Date.now()
if (mode === 'verify') {
const [queue, subjects, consent] = await Promise.all([
const [queue, descriptors] = await Promise.all([
listArchivePhotoFaceIndexQueue({ payload }),
listFaceSubjects(payload),
getConsentByKey(payload, FACE_INDEX_CONSENT_KEY),
payload.count({
collection: 'archivePhotoFace',
where: { model: { equals: FACE_SEARCH_MODEL } },
// Intentional bypass: the batch CLI is a trusted operator with no session.
overrideAccess: true,
}),
])
const inventory = summarizeFaceIndexInventory({
queue,
subjects,
currentConsentHash: consent?.contentHash ?? null,
})
const inventory = summarizeFaceIndexInventory({ queue, descriptors: descriptors.totalDocs })
const report = {
runAt,
mode,
@ -160,21 +155,9 @@ async function main() {
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`)
die(`${inventory.stale} foto(s) desatualizada(s) — rode o --apply`)
}
if (problems.length > 0) die(problems.join('; '))
process.exit(0)
}
@ -194,10 +177,9 @@ async function main() {
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: [] },
summary: { indexed: 0, failed: 0, descriptors: 0, failures: [] },
preview: queue.items.slice(0, options.limit ?? 20).map((item) => ({ photoId: item.id })),
durationMs: Date.now() - startedAt,
}
@ -207,10 +189,9 @@ async function main() {
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).',
)
if (queue.items.length === 0) {
console.log('[faces:index] nada a fazer — todas as fotos aprovadas estão na revisão atual.')
process.exit(0)
}
const engine = await createNodeFaceEngine(process.cwd()).catch((error) => {
@ -229,8 +210,8 @@ async function main() {
const detail =
result.status === 'failed'
? ` [${result.stage}]: ${result.error}`
: result.matchedSubjects.length > 0
? ` → ${result.matchedSubjects.length} vínculo(s)`
: result.descriptorCount > 0
? ` → ${result.descriptorCount} rosto(s)`
: ''
console.log(`[faces:index] ${result.status}${detail} foto ${item.id}`)
}
@ -245,7 +226,6 @@ async function main() {
indexKey: queue.indexKey,
totalApproved: queue.totalApproved,
stale: queue.stale,
eligibleSubjects: queue.eligibleSubjects,
items: queue.items.length,
},
summary,

View file

@ -0,0 +1,83 @@
/**
* C242 — pure planning/reporting of `pnpm archive:publish`: the argv parser,
* the run summary and the human lines + JSON receipt. No I/O of its own, so the
* unit spec drives it with fakes.
*/
const USAGE = 'uso: `pnpm archive:publish [--apply] [--limit <n>] [--out <dir>]`'
export const ARCHIVE_PUBLISH_DEFAULT_OUT_DIR = 'data/archive'
export const archivePublishReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-')
/**
* @param {string[]} [argv]
* @returns {{ apply: boolean, limit: number | null, out: string, help: boolean }}
*/
export const parseArchivePublishCliArgs = (argv = process.argv.slice(2)) => {
const options = { apply: false, limit: null, out: ARCHIVE_PUBLISH_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 === '--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.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
}
/**
* @param {Array<{ photoId: number, status: string, error?: string | null }>} results
*/
export const summarizeArchivePublishResults = (results) => {
const failures = []
let approved = 0
let failed = 0
for (const result of results) {
if (result.status === 'approved') approved += 1
else {
failed += 1
failures.push({ photoId: result.photoId, error: result.error ?? null })
}
}
return { approved, failed, failures }
}
export const formatArchivePublishReport = (report) => {
const lines = [
`[archive:publish] modo: ${report.mode} | alvo: ${report.target}`,
`[archive:publish] drafts elegíveis: ${report.queue.totalDrafts}`,
]
if (report.mode !== 'plan') {
lines.push(
`[archive:publish] aprovadas: ${report.summary.approved} | falharam: ${report.summary.failed}`,
)
}
for (const failure of report.summary?.failures ?? []) {
lines.push(`[archive:publish] ✗ foto ${failure.photoId}: ${failure.error}`)
}
return lines
}

View file

@ -228,15 +228,15 @@ export const E2E_AFFECTED_MANIFEST = [
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).
// C242 — the selfie search: the `/fotos/encontre` page (already matched by
// the C233 prefix above), the anonymous descriptor endpoint, the anonymous
// photo-face index 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/utilities/faceIndex',
'src/collections/ArchivePhotoFace.ts',
'src/collections/Consent.ts',
'src/utilities/campaignConsent.ts',
'src/lib/campaignConsentKeys.ts',

View file

@ -0,0 +1,52 @@
/**
* C242 — the legal texts the selfie search resolves by stable key, versioned in
* the repo so the delivery that opens the surface owns what the visitor reads:
*
* - `busca-selfie-fotos` is the QUERY consent: authorizes the use of the
* visitor's own selfie/biometrics for this one search, on device.
* - `busca-selfie-indice` is the PUBLIC NOTICE of the anonymous archive index
* (scope B): transparency about the descriptor index over approved photos
* and the self-service opt-out. It is not an enrollment consent — nobody is
* individually enrolled.
*
* Both are written to the `consent` collection by `pnpm seed:face-consents`
* (create-if-missing, idempotent, guarded); editing a text here and re-running
* updates the row (versioned by Payload).
*/
/** Builds the Lexical root of a Consent text from plain paragraphs. */
export const buildConsentRichText = (paragraphs) => ({
root: {
type: 'root',
children: paragraphs.map((text) => ({
type: 'paragraph',
children: [{ type: 'text', text, version: 1 }],
direction: null,
format: '',
indent: 0,
version: 1,
})),
direction: null,
format: '',
indent: 0,
version: 1,
},
})
export const FACE_SEARCH_CONSENT_PARAGRAPHS = [
'Autorizo, de forma livre, informada e específica, o uso da minha selfie e dos dados biométricos faciais dela extraídos exclusivamente para esta consulta: procurar, entre as fotos públicas aprovadas do acervo do mandato de Jorge Solla, aquelas em que eu apareço.',
'O reconhecimento acontece no meu próprio aparelho. A imagem da selfie não é enviada nem armazenada; ao servidor é enviado apenas o vetor matemático necessário para a comparação, que é descartado ao fim da consulta e não fica guardado.',
'Estou ciente de que o resultado não mostra nomes de terceiros e não informa grau de semelhança. A busca pode não encontrar fotos, e posso interrompê-la a qualquer momento.',
'A base legal é o consentimento específico para dados pessoais sensíveis (art. 11, I, da LGPD). Posso retirar este consentimento a qualquer momento, sem custo, pelo próprio fluxo ou pelos canais do mandato — a retirada não afeta o que já foi realizado.',
'Controlador: campanha Jorge Solla 1313. Mais detalhes na Política de Privacidade do site.',
]
export const FACE_INDEX_CONSENT_PARAGRAPHS = [
'Aviso público sobre o índice de fotos: as fotos públicas aprovadas do acervo podem conter rostos de várias pessoas. Para que cada pessoa encontre as próprias fotos pela busca por selfie, o sistema mantém um índice biométrico anônimo — vetores matemáticos extraídos dos rostos presentes nessas fotos, sem nome, sem vínculo com cadastro de pessoas e sem qualquer identificação de quem aparece.',
'O índice serve apenas para localizar as fotos da própria pessoa que faz a busca. Ele nunca é usado para identificar, nomear ou listar terceiros, e nunca cruza os dados biométricos com contatos, lideranças ou apoiadores.',
'Qualquer pessoa pode retirar seu rosto do índice a qualquer momento, sem custo: use a opção “Minha presença” na página da busca e siga as instruções. A retirada apaga do índice as referências biométricas correspondentes, e o rosto deixa de ser encontrado.',
'Para pedir a remoção de uma foto específica, use o canal indicado no álbum público. Fotos que não estejam aprovadas para o público não fazem parte do índice.',
]
export const FACE_SEARCH_CONSENT_TEXT = buildConsentRichText(FACE_SEARCH_CONSENT_PARAGRAPHS)
export const FACE_INDEX_CONSENT_TEXT = buildConsentRichText(FACE_INDEX_CONSENT_PARAGRAPHS)

View file

@ -1,91 +0,0 @@
/**
* 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

@ -1,7 +1,7 @@
/**
* C234 — pure planning/reporting of `pnpm faces:index`: the argv parser, the
* C242 — 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
* I/O of its own — the queue, the descriptor counts and the results arrive as
* parameters, so the unit spec drives everything with fakes.
*/
@ -61,24 +61,21 @@ export const parseFaceIndexCliArgs = (argv = process.argv.slice(2)) => {
}
/**
* Aggregates one apply run: photos indexed (and how many subjects each linked),
* Aggregates one apply run: photos indexed (and how many faces each stored),
* photos refused by the engine/IO with stage+reason.
*
* @param {Array<{ photoId: number, status: string, matchedSubjects?: number[], stage?: string | null, error?: string | null }>} results
* @param {Array<{ photoId: number, status: string, descriptorCount?: 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
let descriptors = 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)
descriptors += result.descriptorCount ?? 0
} else if (result.status === 'failed') {
failed += 1
failures.push({
@ -89,48 +86,23 @@ export const summarizeFaceIndexResults = (results) => {
}
}
return {
indexed,
failed,
linkedPhotos,
linksBySubject: [...linkedBySubject.entries()]
.sort((a, b) => a[0] - b[0])
.map(([subjectId, photos]) => ({ subjectId, photos })),
failures,
}
return { indexed, failed, descriptors, 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.
* marker is not the current index revision (never indexed or a model change).
* `descriptors` is the stored face count of the current model.
*
* @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
* @param {{ queue: { indexKey: string, totalApproved: number, stale: number }, descriptors: number }} 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,
}
}
export const summarizeFaceIndexInventory = ({ queue, descriptors }) => ({
indexKey: queue.indexKey,
totalApproved: queue.totalApproved,
indexed: Math.max(0, queue.totalApproved - queue.stale),
stale: queue.stale,
descriptors,
})
/** One line per counted state of the inventory (pt-BR, operator-facing). */
export const formatFaceIndexInventory = (inventory, model) => [
@ -138,28 +110,20 @@ export const formatFaceIndexInventory = (inventory, 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)`,
),
`[faces:index] rostos no índice (modelo atual): ${inventory.descriptors}`,
]
/** 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})`,
`[faces:index] fila: ${report.queue.items} foto(s) a processar (aprovadas: ${report.queue.totalApproved})`,
]
if (report.mode !== 'plan') {
lines.push(
`[faces:index] processadas: ${report.summary.indexed} | vinculadas a alguém: ${report.summary.linkedPhotos} | falharam: ${report.summary.failed}`,
`[faces:index] processadas: ${report.summary.indexed} | rostos indexados: ${report.summary.descriptors} | 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 ?? []) {

View file

@ -44,6 +44,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/agent-session.mjs',
'scripts/lib/ansi.mjs',
'scripts/lib/archiveCatalogPlan.mjs',
'scripts/lib/archivePublishPlan.mjs',
'scripts/lib/archiveVisionApi.mjs',
'scripts/lib/auto-unblock.mjs',
'scripts/lib/briefingContent.mjs',
@ -78,7 +79,7 @@ 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/faceConsentTexts.mjs',
'scripts/lib/faceIndexPlan.mjs',
'scripts/lib/flickrApi.mjs',
'scripts/lib/flickrPlan.mjs',

View file

@ -0,0 +1,213 @@
/**
* C242 — publishes the photo archive in bulk: every `draft` photo becomes
* `approved` so the public album (/fotos, C233) and the selfie search (C234/
* C242) have what to show. The curation act is explicitly human and bulk
* (decision of the owner, 2026-10-01); `removed` is NEVER touched — a takedown
* stays a takedown.
*
* Modes:
* pnpm archive:publish plan/dry-run (default) — the eligible queue; no write
* pnpm archive:publish --apply approves the drafts; requires
* ARCHIVE_PUBLISH_CONFIRM=1 outside local dev
* and the declared TEQO_ENV matching the
* exact database
*
* Options: --limit <n> (canary), --out <dir> (receipts, default `data/archive`),
* --help.
*
* Preconditions enforced fail-closed: the album global must have a valid
* removal channel configured (the same guard the per-photo approval uses) —
* a public photo always carries the takedown path.
*
* Runbook (after --apply, bust the public cache from a process that owns it):
* curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
* -H "x-revalidate-secret: $REVALIDATE_SECRET"
*/
import { join } from 'node:path'
import { getPayload } from 'payload'
import { isArchivePhotoRemovalChannelUrl } from '../src/lib/archivePhotoCatalog.ts'
import {
ARCHIVE_PUBLISH_DEFAULT_OUT_DIR,
archivePublishReportStamp,
formatArchivePublishReport,
parseArchivePublishCliArgs,
summarizeArchivePublishResults,
} from './lib/archivePublishPlan.mjs'
import {
TEQO_ENV_DATABASE_BY_ENV,
assertEnvironmentDatabaseTarget,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
loadCliEnv,
writeRepoFile,
} from './lib/cli.mjs'
loadCliEnv()
const die = dieWithLabel('archive:publish')
const WRITE_CONFIRM_FLAG = 'ARCHIVE_PUBLISH_CONFIRM'
const PAGE_SIZE = 200
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
.map(([environment, database]) => `${environment} (${database})`)
.join(' | ')
const HELP = `
Uso: pnpm archive:publish [opções]
Aprova em lote as fotos DRAFT do acervo (publicationStatus: draft → approved),
para o álbum público e a busca por selfie. Fotos "removed" nunca são tocadas.
Modos:
(sem flag) plano/dry-run — fila elegível; sem escrita
--apply aprova os drafts (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local)
Opções:
--limit <n> processa no máximo n fotos (canário)
--out <dir> diretório dos recibos (default ${ARCHIVE_PUBLISH_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 plano é read-only.
`
const writeReport = async (options, report, suffix = '') => {
const stamp = archivePublishReportStamp(report.runAt)
const relativePath = join(options.out, 'reports', `archive-publish-${stamp}${suffix}.json`)
await writeRepoFile({
label: 'archive:publish',
root: process.cwd(),
relativePath,
body: JSON.stringify(report, null, 2),
})
return join(process.cwd(), relativePath)
}
async function main() {
const options = parseArchivePublishCliArgs(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()
console.log(`[archive:publish] alvo: ${databaseTarget()} | modo: ${mode}`)
if (mode === 'apply') {
assertWriteConfirm({
label: 'archive:publish',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm archive:publish --apply',
})
assertEnvironmentDatabaseTarget()
}
const config = (await import('../src/payload.config.ts')).default
const payload = await getPayload({ config })
const startedAt = Date.now()
const album = await payload
.findGlobal({ slug: 'photoAlbum', depth: 0, overrideAccess: true })
.catch(() => null)
if (!isArchivePhotoRemovalChannelUrl(album?.removalChannelUrl)) {
die(
'canal de remoção não configurado no global Álbum de fotos — aprove pelo admin antes do lote (a foto pública sempre carrega o caminho de remoção).',
)
}
const drafts = await payload.find({
collection: 'archivePhoto',
where: { publicationStatus: { equals: 'draft' } },
sort: ['id'],
depth: 0,
limit: 0,
pagination: false,
select: { id: true },
// Intentional bypass: the publish CLI is a trusted operator with no session.
overrideAccess: true,
})
const queue = options.limit ? drafts.docs.slice(0, options.limit) : drafts.docs
if (mode === 'plan') {
const report = {
runAt,
mode,
target: databaseTarget(),
queue: { totalDrafts: drafts.docs.length, items: queue.length },
summary: { approved: 0, failed: 0, failures: [] },
preview: queue.slice(0, 20).map((doc) => ({ photoId: doc.id })),
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatArchivePublishReport(report)) console.log(line)
console.log(`\n[archive:publish] recibo JSON: ${reportPath}`)
process.exit(0)
}
if (queue.length === 0) {
console.log('[archive:publish] nada a fazer — nenhuma foto draft elegível.')
process.exit(0)
}
const results = []
for (let index = 0; index < queue.length; index += 1) {
const photo = queue[index]
try {
await payload.update({
collection: 'archivePhoto',
id: photo.id,
data: { publicationStatus: 'approved' },
depth: 0,
// Intentional bypass: same trusted operator.
overrideAccess: true,
})
results.push({ photoId: photo.id, status: 'approved' })
} catch (error) {
results.push({
photoId: photo.id,
status: 'failed',
error: error?.message ?? String(error),
})
}
if ((index + 1) % PAGE_SIZE === 0) {
console.log(`[archive:publish] … ${index + 1}/${queue.length}`)
}
}
const summary = summarizeArchivePublishResults(results)
const report = {
runAt,
mode,
target: databaseTarget(),
queue: { totalDrafts: drafts.docs.length, items: queue.length },
summary,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatArchivePublishReport(report)) console.log(line)
console.log(
'\n[archive:publish] lembre de bustar a tag pública (processo Next): curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" -H "x-revalidate-secret: $REVALIDATE_SECRET"',
)
console.log(`[archive:publish] recibo JSON: ${reportPath}`)
if (summary.approved === 0 && results.length > 0) {
die('nenhuma foto foi aprovada — falhas listadas no recibo.')
}
process.exit(0)
}
main().catch((error) => {
die(error?.message || String(error))
})

View file

@ -0,0 +1,159 @@
/**
* C242 — provisions the two Consent texts of the selfie search by their stable
* keys (`busca-selfie-fotos` query consent, `busca-selfie-indice` public index
* notice). Idempotent: creates the row when missing, updates the text when the
* repo text changed (Payload versions the consent).
*
* Modes:
* pnpm seed:face-consents plan/dry-run (default) — shows the state; no write
* pnpm seed:face-consents --apply upserts the texts; requires
* FACE_CONSENT_SEED_CONFIRM=1 outside local dev
* and the declared TEQO_ENV matching the
* exact database
*
* The texts are versioned in `scripts/lib/faceConsentTexts.mjs` — the delivery
* that opens the surface owns what the visitor reads.
*/
import { getPayload } from 'payload'
import { FACE_INDEX_CONSENT_KEY, FACE_SEARCH_CONSENT_KEY } from '../src/lib/campaignConsentKeys.ts'
import {
TEQO_ENV_DATABASE_BY_ENV,
assertEnvironmentDatabaseTarget,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
loadCliEnv,
} from './lib/cli.mjs'
import {
FACE_INDEX_CONSENT_PARAGRAPHS,
FACE_INDEX_CONSENT_TEXT,
FACE_SEARCH_CONSENT_PARAGRAPHS,
FACE_SEARCH_CONSENT_TEXT,
} from './lib/faceConsentTexts.mjs'
loadCliEnv()
const die = dieWithLabel('seed:face-consents')
const WRITE_CONFIRM_FLAG = 'FACE_CONSENT_SEED_CONFIRM'
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
.map(([environment, database]) => `${environment} (${database})`)
.join(' | ')
const HELP = `
Uso: pnpm seed:face-consents [opções]
Cria/atualiza os dois Consentimentos da busca por selfie:
${FACE_SEARCH_CONSENT_KEY} consentimento da consulta (selfie + biometria)
${FACE_INDEX_CONSENT_KEY} aviso público do índice anônimo do acervo
Modos:
(sem flag) plano/dry-run — estado atual e prévia dos textos; sem escrita
--apply grava os textos (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local)
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 plano é read-only.
`
const upsertConsent = async (payload, key, text) => {
const existing = await payload.find({
collection: 'consent',
where: { key: { equals: key } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const found = existing.docs[0]
if (found) {
await payload.update({
collection: 'consent',
id: found.id,
data: { text },
depth: 0,
overrideAccess: true,
})
return { id: found.id, action: 'updated' }
}
const created = await payload.create({
collection: 'consent',
data: { key, text },
depth: 0,
overrideAccess: true,
})
return { id: created.id, action: 'created' }
}
async function main() {
const argv = process.argv.slice(2)
if (argv.includes('--help') || argv.includes('-h')) {
console.log(HELP)
process.exit(0)
}
const unknown = argv.find((arg) => arg !== '--apply')
if (unknown) die(`argumento desconhecido: ${unknown} — uso: pnpm seed:face-consents [--apply].`)
const apply = argv.includes('--apply')
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
console.log(`[seed:face-consents] alvo: ${databaseTarget()} | modo: ${apply ? 'apply' : 'plan'}`)
if (apply) {
assertWriteConfirm({
label: 'seed:face-consents',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm seed:face-consents --apply',
})
assertEnvironmentDatabaseTarget()
}
const config = (await import('../src/payload.config.ts')).default
const payload = await getPayload({ config })
const entries = [
{
key: FACE_SEARCH_CONSENT_KEY,
text: FACE_SEARCH_CONSENT_TEXT,
paragraphs: FACE_SEARCH_CONSENT_PARAGRAPHS,
},
{
key: FACE_INDEX_CONSENT_KEY,
text: FACE_INDEX_CONSENT_TEXT,
paragraphs: FACE_INDEX_CONSENT_PARAGRAPHS,
},
]
for (const entry of entries) {
const existing = await payload.find({
collection: 'consent',
where: { key: { equals: entry.key } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const state = existing.docs[0] ? `existe (#${existing.docs[0].id})` : 'ausente'
console.log(
`[seed:face-consents] ${entry.key}: ${state} | texto com ${entry.paragraphs.length} parágrafo(s)`,
)
if (!apply) continue
const result = await upsertConsent(payload, entry.key, entry.text)
console.log(`[seed:face-consents] ${entry.key}: ${result.action} (#${result.id})`)
}
if (!apply) {
console.log('\n[seed:face-consents] dry-run — use --apply para gravar.')
process.exit(0)
}
console.log('\n[seed:face-consents] OK — Consentimentos da busca por selfie provisionados.')
process.exit(0)
}
main().catch((error) => {
die(error?.message || String(error))
})

View file

@ -6,10 +6,12 @@ import { CampaignHero } from '@/components/CampaignHero'
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
import { CampaignStorySection } from '@/components/CampaignStorySection'
import { ContentPieceHomeSection } from '@/components/conteudos/ContentPieceHomeSection'
import { SelfieSearchHomeSection } from '@/components/fotos/SelfieSearchHomeSection'
import { JingleHomeSection } from '@/components/jingles/JingleHomeSection'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
import { getPublishedContentPieceItems } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { getPublishedJingleItems } from '@/utilities/jingleReads'
import type { ReactNode } from 'react'
import { CampaignCardsSection } from './CampaignCardsSection'
@ -123,13 +125,18 @@ export default async function HomePage() {
// S27/S39 — one cached listing (tag `contentPieces`) feeds both the discovery
// flag of the footer and the home sample; zero pieces hides both (fail-closed).
// C233 — the same contract for the "Fotos" discovery flag (tag `archivePhotos`).
const [pixelId, jingles, contentPieces, showFotos] = await Promise.all([
const [pixelId, jingles, contentPieces, showFotos, album] = await Promise.all([
getCampaignHomeMetaPixelId(),
getPublishedJingleItems(),
getPublishedContentPieceItems(),
hasPublishedArchivePhotos(),
getCachedGlobal('photoAlbum')(),
])
const homeJingles = jingles.slice(0, 3)
// C243 — the discovery section only exists while the selfie search is open
// (album published + own flag + approved photos); closed leaves no dead CTA.
const showSelfieSearch =
album.published !== false && album.selfieSearchEnabled === true && showFotos
return (
<>
@ -255,6 +262,7 @@ export default async function HomePage() {
<JingleHomeSection jingles={homeJingles} showAll={jingles.length > homeJingles.length} />
<CampaignCardsSection />
{contentPieces.length > 0 ? <ContentPieceHomeSection items={contentPieces} /> : null}
{showSelfieSearch ? <SelfieSearchHomeSection /> : null}
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
</main>

View file

@ -3,12 +3,7 @@ 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 { FACE_SEARCH_RESULT_LIMIT, toFaceSearchPhotoView } from '@/lib/faceSearch'
import { faceSearchRequestSchema } from '@/lib/schemas/faceSearch'
import { getApprovedArchivePhotoItems } from '@/utilities/archivePhotos/archivePhotoReads'
import { readBoundedRequestBody } from '@/utilities/boundedRequestBody'
@ -17,28 +12,31 @@ import {
checkContentEventRateLimit,
contentEventClientKey,
} from '@/utilities/content/contentEventRateLimit'
import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment'
import {
getFaceSubjectMatchedPhotoIds,
listSearchableFaceSubjects,
} from '@/utilities/faceSubjects/faceSubjectReads'
deleteFaceDescriptorMatches,
findMatchedFacePhotoIds,
loadFaceDescriptorIndex,
} from '@/utilities/faceIndex/faceDescriptorReads'
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.
* C242 — the anonymous endpoint of the selfie search (scope B: the anonymous
* descriptor index of the approved archive, decision of 2026-10-01). 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.
* the album's `selfieSearchEnabled` kill switch AND both configured Consents
* resolved server-side — the query consent (`busca-selfie-fotos`, accepted by
* the visitor) and the public index notice (`busca-selfie-indice`, the
* transparency document of the anonymous index). `leave-index` is authorized
* by the matched descriptor itself and erases the person's faces from the
* index.
*/
const NO_STORE = { 'Cache-Control': 'no-store' } as const
@ -79,45 +77,33 @@ export const POST = async (request: Request): Promise<NextResponse> => {
}
const payload = await getPayload({ config })
// Direct read (no cache): the kill switch and the consent must be the state
// Direct read (no cache): the kill switch and the consents 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([
const [album, searchConsent, indexNotice] = await Promise.all([
payload.findGlobal({ slug: 'photoAlbum', depth: 0 }).catch(() => null),
getConsentByKey(payload, FACE_SEARCH_CONSENT_KEY),
getConsentByKey(payload, FACE_INDEX_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 (!searchConsent || !indexNotice) return jsonResponse({ ok: false, error: 'consent' }, 503)
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 })
const removedFaces = await deleteFaceDescriptorMatches({
payload,
vector: parsed.data.vector,
})
return jsonResponse({ ok: true, removed: removedFaces > 0 })
}
if (!match) return jsonResponse({ ok: true, found: false })
const index = await loadFaceDescriptorIndex(payload)
const matchedIds = findMatchedFacePhotoIds({ vector: parsed.data.vector, entries: index })
if (matchedIds.length === 0) return jsonResponse({ ok: true, found: false })
const [matchedIds, approved] = await Promise.all([
getFaceSubjectMatchedPhotoIds(payload, match.id),
getApprovedArchivePhotoItems(),
])
const approved = await getApprovedArchivePhotoItems()
const linked = new Set(matchedIds)
const photos = approved
.filter((item) => linked.has(item.id))

View file

@ -14,7 +14,7 @@ import {
ARCHIVE_PHOTO_ALBUM_ENTRY_PATH,
ARCHIVE_PHOTO_ALBUM_PATH,
} from '@/lib/archivePhotoPublicCatalog'
import { FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import { FACE_INDEX_CONSENT_KEY, FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import { getConsentByKey } from '@/utilities/campaignConsent'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
@ -79,7 +79,10 @@ export default async function FotosEncontrePage() {
if (album.published === false || album.selfieSearchEnabled !== true) notFound()
const payload = await getPayload({ config })
const consent = await getConsentByKey(payload, FACE_SEARCH_CONSENT_KEY)
const [consent, indexNotice] = await Promise.all([
getConsentByKey(payload, FACE_SEARCH_CONSENT_KEY),
getConsentByKey(payload, FACE_INDEX_CONSENT_KEY),
])
const removalChannelUrl = isArchivePhotoRemovalChannelUrl(album.removalChannelUrl)
? album.removalChannelUrl.trim()
: null
@ -98,9 +101,10 @@ export default async function FotosEncontrePage() {
</Link>
</nav>
</div>
{consent ? (
{consent && indexNotice ? (
<SelfieSearchFlow
consentText={<ConsentText data={consent.text} />}
noticeText={<ConsentText data={indexNotice.text} />}
removalChannelUrl={removalChannelUrl}
/>
) : (

View file

@ -27,6 +27,7 @@ import {
import { SPEECH_TOPICS } from '@/lib/speechFacets'
import { canReadArchivePhoto, payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateArchivePhotosListing } from '@/utilities/documents'
import { purgeFaceDescriptorsForPhoto } from '@/utilities/faceIndex/faceDescriptorIndex'
/**
* C231 — private upload collection of the Flickr photo archive: the ORIGINAL
@ -282,6 +283,24 @@ const revalidateArchivePhotosListingAfterDelete: CollectionAfterDeleteHook = ({
return doc
}
/**
* C242 — a photo that stops being `approved` cannot keep answering the selfie
* search: the anonymous descriptor rows die with the public status (and on
* deletion), the same fail-closed spirit of the C233 read. The FK cascade
* covers direct SQL deletes; this covers the Local API lifecycle.
*/
const purgeFaceDescriptorsAfterChange: CollectionAfterChangeHook = async ({ doc, req }) => {
if (doc.publicationStatus !== 'approved') {
await purgeFaceDescriptorsForPhoto({ payload: req.payload, photoId: doc.id, req })
}
return doc
}
const purgeFaceDescriptorsAfterDelete: CollectionAfterDeleteHook = async ({ doc, req }) => {
await purgeFaceDescriptorsForPhoto({ payload: req.payload, photoId: doc.id, req })
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
@ -346,8 +365,8 @@ export const ArchivePhoto: CollectionConfig = {
},
hooks: {
beforeValidate: [deriveArchivePhotoCatalogIndex, requireRemovalChannelForApproval],
afterChange: [revalidateArchivePhotosListingAfterChange],
afterDelete: [revalidateArchivePhotosListingAfterDelete],
afterChange: [revalidateArchivePhotosListingAfterChange, purgeFaceDescriptorsAfterChange],
afterDelete: [revalidateArchivePhotosListingAfterDelete, purgeFaceDescriptorsAfterDelete],
},
fields: [
{

View file

@ -0,0 +1,79 @@
import type { CollectionConfig } from 'payload'
import { payloadAdminOnly } from '@/utilities/campaignAccess'
/**
* C242 — one detected face of an APPROVED archive photo: the anonymous index
* that lets any visitor find themselves (scope B of the gate, decision of
* 2026-10-01; supersedes the A/C `faceSubject` enrollment). The row carries no
* identity, no link to `Contact`/leadership and no name — only the photo it was
* found in, the 128-float descriptor and the model that produced it.
*
* Machine-only data: no admin surface (`admin.hidden`), admin-only access, and
* the descriptor never leaves the server. Written exclusively by the
* `pnpm faces:index` batch (trusted operator, `overrideAccess`), deleted by the
* opt-out (`leave-index` matches the person's own descriptor) and by the photo
* lifecycle hooks on unapproval/deletion. A photo that is not `approved` must
* never have rows here.
*/
export const ArchivePhotoFace: CollectionConfig = {
slug: 'archivePhotoFace',
labels: {
singular: 'Rosto no índice',
plural: 'Rostos no índice',
},
admin: {
group: 'Comunicação',
hidden: true,
description:
'Índice facial anônimo do acervo público aprovado (C242). Operado pela CLI faces:index; o descriptor nunca é exposto.',
},
access: {
create: payloadAdminOnly,
read: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
fields: [
{
name: 'photo',
type: 'relationship',
relationTo: 'archivePhoto',
label: 'Foto',
required: true,
index: true,
admin: {
description: 'Foto aprovada em que o rosto foi detectado.',
},
},
{
name: 'model',
type: 'text',
label: 'Modelo',
required: true,
index: true,
admin: {
readOnly: true,
description:
'Modelo do descriptor (troca de modelo invalida a revisão e o lote reprocessa).',
},
},
{
name: 'detectedAt',
type: 'date',
label: 'Detectado em',
admin: {
readOnly: true,
},
},
{
name: 'vector',
type: 'json',
label: 'Descriptor',
admin: {
hidden: true,
readOnly: true,
},
},
],
}

View file

@ -22,7 +22,6 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({
leaderships,
supporters,
voteSupporters,
faceSubjects,
] = await Promise.all([
req.payload.count({
collection: 'subscription',
@ -66,14 +65,6 @@ 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]> = [
@ -84,7 +75,6 @@ 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

@ -1,135 +0,0 @@
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

@ -24,11 +24,12 @@ export const SelfieSearchEntry = () => (
</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.
Sua selfie fica neste aparelho. A busca cobre o acervo público aprovado, e você pode
sair do í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.
Use uma selfie sua. A imagem não sai deste dispositivo. A busca cobre o acervo público
aprovado, e você pode retirar seu rosto do índice.
</span>
</p>
</div>

View file

@ -72,9 +72,11 @@ const STEP_SHELL = 'mx-auto w-full max-w-[650px]'
*/
export const SelfieSearchFlow = ({
consentText,
noticeText,
removalChannelUrl,
}: {
consentText: ReactNode
noticeText: ReactNode
removalChannelUrl: string | null
}) => {
const [step, setStep] = useState<FlowStep>({ kind: 'consent' })
@ -234,8 +236,34 @@ export const SelfieSearchFlow = ({
>
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 className="mt-3 text-sm leading-6 text-(--campaign-muted)">{consentText}</div>
<div className="mt-4 rounded-xl border border-[#184e92]/20 bg-[#eef4fb] p-4 text-sm leading-6">
<div className="flex items-start gap-3">
<ShieldCheck
aria-hidden="true"
className="mt-0.5 size-[18px] shrink-0 text-[#184e92]"
strokeWidth={2}
/>
<div>
<b className="text-[#184e92]">Aviso público sobre o índice de fotos</b>
<p className="mt-1 text-(--campaign-muted)">
Rostos presentes nas fotos públicas aprovadas compõem um índice sem nomes e sem
vínculo com cadastros de pessoas. Ele serve apenas para localizar as próprias
fotos. Você pode retirar seu rosto do índice a qualquer momento em “Minha
presença”.
</p>
<details className="mt-1">
<summary
className={`inline-flex min-h-11 cursor-pointer items-center text-sm font-extrabold text-[#184e92] underline underline-offset-4 ${ARCHIVE_PHOTO_FOCUS}`}
>
Ler o aviso completo do índice
</summary>
<div className="mt-2 max-h-60 overflow-y-auto rounded-lg border border-(--campaign-line) bg-white p-3 text-(--campaign-muted)">
{noticeText}
</div>
</details>
</div>
</div>
</div>
<div className="mt-5 grid gap-3 rounded-xl bg-[#f9faff] p-4 text-sm">
<div className="flex gap-3">
@ -251,10 +279,11 @@ export const SelfieSearchFlow = ({
<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>
<b>Você mantém o controle.</b>
<br />
<span className="text-(--campaign-muted)">
O resultado não mostra nomes de terceiros nem percentual de semelhança.
Saia do índice quando quiser. O resultado não mostra nomes de terceiros nem
percentual de semelhança.
</span>
</p>
</div>
@ -267,8 +296,8 @@ export const SelfieSearchFlow = ({
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.
Li o aviso e autorizo o uso da minha selfie e da minha biometria somente nesta
consulta para procurar a mim mesmo no álbum.
</span>
</label>
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
@ -456,7 +485,7 @@ export const SelfieSearchFlow = ({
<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."
body="Pode não haver uma foto sua entre os registros públicos aprovados, ou seu rosto pode já ter sido retirado do índice."
note="Não mostramos pessoas parecidas nem tentamos adivinhar quem você é."
action={
<button
@ -513,24 +542,28 @@ export const SelfieSearchFlow = ({
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
Você controla sua presença no índice
</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.
Seu rosto pode fazer parte do índice anônimo por aparecer no acervo aprovado. Para
retirá-lo, use uma selfie sua. Para retirar uma foto pública, fale com a equipe do
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]"
<label className="mt-5 flex cursor-pointer gap-3 rounded-xl border-2 border-[#184e92] bg-[#f9faff] p-4">
<input
type="radio"
name="presence-action"
value="leave-index"
defaultChecked
className="mt-0.5 size-5 shrink-0 accent-[#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.
Apaga do índice as referências biométricas que correspondem ao seu rosto.
</small>
</span>
</div>
</label>
{removalChannelUrl ? (
<a
href={removalChannelUrl}
@ -565,9 +598,9 @@ export const SelfieSearchFlow = ({
{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."
title="Pronto — seu rosto saiu do índice"
body="Apagamos as referências biométricas que correspondiam ao seu rosto. Ele deixa de ser encontrado pela busca por selfie."
note="As fotos públicas continuam no álbum. Para retirar uma foto específica, use o canal da equipe responsável pelo acervo."
action={
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
@ -576,6 +609,16 @@ export const SelfieSearchFlow = ({
Voltar ao álbum
</Link>
}
secondary={
removalChannelUrl ? (
<a
href={removalChannelUrl}
className={`mt-3 inline-flex min-h-11 items-center text-sm font-extrabold text-[#184e92] underline underline-offset-4 ${ARCHIVE_PHOTO_FOCUS}`}
>
Pedir remoção de uma foto
</a>
) : null
}
/>
) : null}
@ -583,7 +626,7 @@ export const SelfieSearchFlow = ({
<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."
body="Não encontramos no índice referências biométricas que correspondam ao seu rosto com esta selfie. Nenhuma alteração foi feita."
action={
<button
type="button"
@ -594,7 +637,7 @@ export const SelfieSearchFlow = ({
</button>
}
secondary={
<>
<div className="mt-6 w-full border-t border-(--campaign-line) pt-5">
<p className="text-sm leading-6 text-(--campaign-muted)">
Quer retirar uma foto pública mesmo assim?
</p>
@ -606,10 +649,13 @@ export const SelfieSearchFlow = ({
Pedir remoção de uma foto
</a>
) : null}
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={`${STATE_LINK} mt-4`}>
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`mt-4 inline-flex min-h-11 items-center text-sm font-bold text-(--campaign-muted) ${ARCHIVE_PHOTO_FOCUS}`}
>
Voltar ao álbum
</Link>
</>
</div>
}
/>
) : null}
@ -862,8 +908,8 @@ const LeaveConfirmDialog = ({
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.
Vamos processar sua selfie neste aparelho e, se houver correspondência, apagar do índice
as referências biométricas do seu rosto. A selfie não será enviada.
</p>
<button
type="button"

View file

@ -0,0 +1,153 @@
import { Check, ChevronRight, Images, ShieldCheck, Smartphone, Sparkles } from 'lucide-react'
import Link from 'next/link'
import {
ARCHIVE_PHOTO_ALBUM_ENTRY_PATH,
ARCHIVE_PHOTO_ALBUM_PATH,
} from '@/lib/archivePhotoPublicCatalog'
import { ARCHIVE_PHOTO_FOCUS, ARCHIVE_PHOTO_PRIMARY_BUTTON } from './archivePhotoClasses'
/** `.assurance-mark` of the artifact: azure surface with the yellow radial glow. */
const ASSURANCE_SURFACE =
'rounded-2xl border border-[#184e92]/15 bg-[radial-gradient(circle_at_76%_18%,rgb(255_230_7/66%)_0_8%,transparent_8.5%),linear-gradient(145deg,#f9faff_0%,#eef4fb_100%)]'
/**
* C243 — the selfie-search section of the public campaign homepage (artefato:
* `busca-selfie-secao-home-ui-design.html`, cenas 01/02). It only presents and
* links: no selfie capture, no engine, no archive photos and no counters on the
* home — the sensitive experience starts at `/fotos/encontre`. Rendered by the
* homepage only while the search is open (album published + flag +
* approved photos), so a closed search leaves no dead CTA.
*/
const ASSURANCE_ITEMS = [
{ icon: Smartphone, label: 'Processamento no aparelho' },
{ icon: Images, label: 'Só o acervo público aprovado' },
{ icon: ShieldCheck, label: 'Saída do índice a qualquer momento' },
] as const
export const SelfieSearchHomeSection = () => (
<section
aria-labelledby="selfie-home-title"
data-home-section="selfie-search"
className="relative overflow-hidden border-b border-(--campaign-line) bg-(--campaign-cream)"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-14 -right-14 size-40 rounded-full bg-(--pt-yellow)/45 sm:-top-24 sm:-right-20 sm:size-64"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-32 left-[36%] hidden size-64 rounded-full bg-(--pt-red)/8 sm:block"
/>
<div className="relative mx-auto grid w-full max-w-[1160px] grid-cols-1 items-center gap-8 px-5 py-12 sm:px-10 sm:py-16 lg:grid-cols-[minmax(0,1.1fr)_minmax(360px,.9fr)] lg:gap-16">
<div className="max-w-[620px]">
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
Fotos da nossa caminhada
</p>
<h2
id="selfie-home-title"
className="campaign-section-title display m-0 mt-2 max-w-[560px] border-0 p-0 text-[30px]! leading-[1.02]! font-black tracking-[-0.03em] text-balance lg:text-[38px]!"
>
Encontre você nas fotos
</h2>
<p className="campaign-section-copy mt-4 max-w-[600px] text-(--campaign-muted)">
<span className="sm:hidden">
Use uma selfie sua na página da busca para procurar as fotos públicas aprovadas em que
você aparece.
</span>
<span className="hidden sm:inline">
Use uma selfie sua na página da busca para procurar, entre as fotos públicas aprovadas,
aquelas em que você aparece.
</span>
</p>
<div className="mt-5 hidden max-w-[610px] items-start gap-3 text-sm leading-6 sm:flex">
<span className="mt-0.5 grid size-9 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white">
<ShieldCheck aria-hidden="true" className="size-4" strokeWidth={2} />
</span>
<p className="m-0 text-[#365472]">
<b className="text-[#143c70]">A selfie não sai do seu aparelho.</b> A busca cobre o
índice anônimo do acervo aprovado, e você pode retirar seu rosto quando quiser.
</p>
</div>
<div className="mt-6 hidden flex-wrap items-center gap-x-6 gap-y-2 sm:flex">
<Link
href={ARCHIVE_PHOTO_ALBUM_ENTRY_PATH}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} shrink-0`}
>
Encontrar minhas fotos
<ChevronRight aria-hidden="true" className="size-4" />
</Link>
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md px-2 text-sm font-extrabold text-(--pt-red) underline underline-offset-4 hover:text-[#741414] ${ARCHIVE_PHOTO_FOCUS}`}
>
Ver o álbum
<ChevronRight aria-hidden="true" className="size-4" />
</Link>
</div>
<div className={`mt-6 p-4 sm:hidden ${ASSURANCE_SURFACE}`}>
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white">
<ShieldCheck aria-hidden="true" className="size-5" strokeWidth={2} />
</span>
<div>
<p className="text-sm font-extrabold text-[#143c70]">Sua selfie fica no aparelho</p>
<p className="mt-1 text-xs leading-5 text-[#365472]">
A selfie não sai deste aparelho. A busca cobre o acervo aprovado, e você pode
retirar seu rosto do índice quando quiser.
</p>
</div>
</div>
</div>
<div className="mt-4 grid gap-2 sm:mt-0 sm:hidden">
<Link
href={ARCHIVE_PHOTO_ALBUM_ENTRY_PATH}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full`}
>
Encontrar minhas fotos
<ChevronRight aria-hidden="true" className="size-4" />
</Link>
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`inline-flex min-h-11 w-full items-center justify-center gap-1.5 rounded-md px-2 text-sm font-extrabold text-(--pt-red) underline underline-offset-4 hover:text-[#741414] ${ARCHIVE_PHOTO_FOCUS}`}
>
Ver o álbum
<ChevronRight aria-hidden="true" className="size-4" />
</Link>
</div>
</div>
<div className={`relative hidden overflow-hidden p-6 lg:block ${ASSURANCE_SURFACE}`}>
<span
aria-hidden="true"
className="absolute top-5 right-5 grid size-12 place-items-center rounded-full bg-white text-[#184e92] shadow-[0_8px_28px_rgb(24_78_146/12%)]"
>
<Sparkles aria-hidden="true" className="size-6" strokeWidth={2} />
</span>
<p className="max-w-[250px] font-[family-name:var(--font-exo2)] text-xl leading-tight font-black text-[#143c70]">
Sua imagem fica com você. O controle também.
</p>
<ul className="mt-7 grid list-none gap-0 p-0">
{ASSURANCE_ITEMS.map(({ icon: Icon, label }, index) => (
<li
key={label}
className={`flex items-center gap-3 border-[#184e92]/12 py-4 ${
index === ASSURANCE_ITEMS.length - 1 ? 'border-y' : 'border-t'
}`}
>
<Icon aria-hidden="true" className="size-5 shrink-0 text-[#184e92]" strokeWidth={2} />
<span className="text-sm font-bold text-[#143c70]">{label}</span>
<Check aria-hidden="true" className="ml-auto size-4 text-[#184e92]" strokeWidth={2} />
</li>
))}
</ul>
</div>
</div>
</section>
)

View file

@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
/** Shared class contracts of the public photo album (artefato C233, cenas 01–07). */
export const ARCHIVE_PHOTO_FOCUS =
'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow)'
'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(24_78_146/72%)]'
export const ARCHIVE_PHOTO_CHIP = cn(
'inline-flex min-h-11 items-center gap-1.5 rounded-full border border-(--campaign-line) bg-white px-3 text-xs font-bold text-black',

View file

@ -30,14 +30,15 @@ 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.
* C242 — the public selfie search on the photo album (scope B: the anonymous
* descriptor index of the approved archive). Two keys because the two
* biometric documents are distinct: `FACE_SEARCH_CONSENT_KEY` is the visitor's
* consent for the temporary use of their own face in this query (the vector is
* discarded with the request), while `FACE_INDEX_CONSENT_KEY` is the PUBLIC
* NOTICE of the anonymous archive index (transparency + self-service opt-out;
* there is no per-person enrollment). Both fail closed: without the Consent row
* with the exact key the flow is closed, and the texts are versioned in
* `scripts/lib/faceConsentTexts.mjs` (provisioned by `pnpm seed:face-consents`).
*/
export const FACE_SEARCH_CONSENT_KEY = 'busca-selfie-fotos'
export const FACE_INDEX_CONSENT_KEY = 'busca-selfie-indice'

View file

@ -1,25 +1,26 @@
/**
* C234 — pure contract of the selfie search over the public photo album.
* C242 — pure contract of the selfie search over the public photo album
* (scope B: the anonymous descriptor index of the approved archive, decision
* of 2026-10-01, supersedes the A/C enrollment of C234).
*
* 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.
* descriptor length, the model id, the intents), the distance math, the match
* over photo descriptors (which by SHAPE cannot carry a similarity number),
* the result view model (no third-party names) 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.
* The anonymous photo descriptors live in `archivePhotoFace` (photo-level, no
* identity); a match never resolves a person — only photos. The visitor's
* vector 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. */
/** The engine+model the indexed vectors belong to — a match across models is void. */
export const FACE_SEARCH_MODEL = 'face-api@1.7.15/faceRecognitionNet'
/**
@ -61,8 +62,11 @@ export const readFaceVector = (value: unknown): number[] | null => {
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 => {
/**
* Euclidean distance of same-length descriptors; a length mismatch is never a
* match. Accepts typed arrays so the cached index can stay in `Float32Array`.
*/
export const faceEuclideanDistance = (a: ArrayLike<number>, b: ArrayLike<number>): number => {
if (a.length !== b.length || a.length === 0) return Number.POSITIVE_INFINITY
let sum = 0
@ -73,58 +77,37 @@ export const faceEuclideanDistance = (a: readonly number[], b: readonly number[]
return Math.sqrt(sum)
}
export type FaceSearchSubject = {
id: number
status?: string | null
model?: string | null
consentHash?: string | null
vector?: unknown
/**
* One indexed face of the approved archive: the photo it was found in and its
* descriptor. The row has no identity by construction — this is the scope B
* (anonymous) contract.
*/
export type FaceDescriptorEntry = {
photoId: number
vector: ArrayLike<number>
}
/**
* 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).
* The distinct photo ids with at least one indexed face strictly under
* `maxDistance` from the query vector, in the order the photos appear in the
* index. `FACE_SEARCH_MAX_DISTANCE` is the only threshold: no caller ever
* receives a distance or a score.
*/
export const faceSubjectIsEligible = (
subject: FaceSearchSubject,
{ model, consentHash }: { model: string; consentHash: string },
): boolean =>
subject.status === 'active' && subject.model === model && subject.consentHash === consentHash
export const findFaceDescriptorPhotoIds = (
vector: ArrayLike<number>,
descriptors: readonly FaceDescriptorEntry[],
): number[] => {
const matched: number[] = []
const seen = new Set<number>()
/**
* 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
}
for (const entry of descriptors) {
if (seen.has(entry.photoId)) continue
if (faceEuclideanDistance(vector, entry.vector) >= FACE_SEARCH_MAX_DISTANCE) continue
seen.add(entry.photoId)
matched.push(entry.photoId)
}
return best
return matched
}
/**
@ -165,7 +148,7 @@ export const toFaceSearchPhotoView = (item: ArchivePhotoPublicItem): FaceSearchP
/**
* 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
* spec can seed an indexed face whose vector equals the fixture's without ever
* loading a model. Never used outside the stub seam.
*/
export const faceStubDescriptorFromBytes = (bytes: Uint8Array): number[] => {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "archive_photo_face" (
"id" serial PRIMARY KEY NOT NULL,
"photo_id" integer NOT NULL,
"model" varchar NOT NULL,
"detected_at" timestamp(3) with time zone,
"vector" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "face_subject" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "face_subject_rels" DISABLE ROW LEVEL SECURITY;
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";
DROP TABLE "face_subject" CASCADE;
DROP TABLE "face_subject_rels" CASCADE;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "archive_photo_face_id" integer;
ALTER TABLE "archive_photo_face" ADD CONSTRAINT "archive_photo_face_photo_id_archive_photo_id_fk" FOREIGN KEY ("photo_id") REFERENCES "public"."archive_photo"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "archive_photo_face_photo_idx" ON "archive_photo_face" USING btree ("photo_id");
CREATE INDEX "archive_photo_face_model_idx" ON "archive_photo_face" USING btree ("model");
CREATE INDEX "archive_photo_face_updated_at_idx" ON "archive_photo_face" USING btree ("updated_at");
CREATE INDEX "archive_photo_face_created_at_idx" ON "archive_photo_face" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_archive_photo_face_fk" FOREIGN KEY ("archive_photo_face_id") REFERENCES "public"."archive_photo_face"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_archive_photo_face_id_idx" ON "payload_locked_documents_rels" USING btree ("archive_photo_face_id");
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "face_subject_id";
DROP TYPE "public"."enum_face_subject_status";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): 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_face" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_archive_photo_face_fk";
DROP INDEX "payload_locked_documents_rels_archive_photo_face_id_idx";
DROP TABLE "archive_photo_face" CASCADE;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "face_subject_id" integer;
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");
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "archive_photo_face_id";`)
}

View file

@ -91,6 +91,7 @@ import * as migration_20260926_062039_add_archive_photo from './20260926_062039_
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';
import * as migration_20261001_040904_add_archive_photo_face from './20261001_040904_add_archive_photo_face';
export const migrations = [
{
@ -556,6 +557,11 @@ export const migrations = [
{
up: migration_20260927_193954_add_face_subject.up,
down: migration_20260927_193954_add_face_subject.down,
name: '20260927_193954_add_face_subject'
name: '20260927_193954_add_face_subject',
},
{
up: migration_20261001_040904_add_archive_photo_face.up,
down: migration_20261001_040904_add_archive_photo_face.down,
name: '20261001_040904_add_archive_photo_face'
},
];

View file

@ -96,7 +96,7 @@ export interface Config {
contentPiece: ContentPiece;
contentMedia: ContentMedia;
archivePhoto: ArchivePhoto;
faceSubject: FaceSubject;
archivePhotoFace: ArchivePhotoFace;
contentEvent: ContentEvent;
recording: Recording;
recordingMedia: RecordingMedia;
@ -154,7 +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>;
archivePhotoFace: ArchivePhotoFaceSelect<false> | ArchivePhotoFaceSelect<true>;
contentEvent: ContentEventSelect<false> | ContentEventSelect<true>;
recording: RecordingSelect<false> | RecordingSelect<true>;
recordingMedia: RecordingMediaSelect<false> | RecordingMediaSelect<true>;
@ -1585,33 +1585,22 @@ export interface ArchivePhoto {
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.
* Índice facial anônimo do acervo público aprovado (C242). Operado pela CLI faces:index; o descriptor nunca é exposto.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "faceSubject".
* via the `definition` "archivePhotoFace".
*/
export interface FaceSubject {
export interface ArchivePhotoFace {
id: number;
/**
* Como a assessoria identifica a pessoa; nunca aparece no site.
* Foto aprovada em que o rosto foi detectado.
*/
label: string;
photo: number | ArchivePhoto;
/**
* O Consent da adesão ao índice (chave estável busca-selfie-indice).
* Modelo do descriptor (troca de modelo invalida a revisão e o lote reprocessa).
*/
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;
model: string;
detectedAt?: string | null;
vector?:
| {
[k: string]: unknown;
@ -1621,18 +1610,6 @@ export interface FaceSubject {
| 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;
}
@ -2471,8 +2448,8 @@ export interface PayloadLockedDocument {
value: number | ArchivePhoto;
} | null)
| ({
relationTo: 'faceSubject';
value: number | FaceSubject;
relationTo: 'archivePhotoFace';
value: number | ArchivePhotoFace;
} | null)
| ({
relationTo: 'contentEvent';
@ -3286,18 +3263,13 @@ export interface ArchivePhotoSelect<T extends boolean = true> {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "faceSubject_select".
* via the `definition` "archivePhotoFace_select".
*/
export interface FaceSubjectSelect<T extends boolean = true> {
label?: T;
consent?: T;
consentHash?: T;
export interface ArchivePhotoFaceSelect<T extends boolean = true> {
photo?: T;
model?: T;
enrolledAt?: T;
detectedAt?: T;
vector?: T;
status?: T;
removedAt?: T;
matchedPhotos?: T;
updatedAt?: T;
createdAt?: T;
}
@ -4191,7 +4163,7 @@ export interface TaskCreateCollectionExport {
| 'contentPiece'
| 'contentMedia'
| 'archivePhoto'
| 'faceSubject'
| 'archivePhotoFace'
| 'contentEvent'
| 'recording'
| 'recordingMedia'

View file

@ -12,6 +12,7 @@ import { pt } from 'payload/i18n/pt'
import { Activity } from './collections/Activity'
import { AllocationDecision } from './collections/AllocationDecision'
import { ArchivePhoto } from './collections/ArchivePhoto'
import { ArchivePhotoFace } from './collections/ArchivePhotoFace'
import { CalendarFeed } from './collections/CalendarFeed'
import { CampaignDemand } from './collections/CampaignDemand'
import { CampaignInvite } from './collections/CampaignInvite'
@ -26,7 +27,6 @@ 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'
@ -140,7 +140,7 @@ export default buildConfig({
ContentPiece,
ContentMedia,
ArchivePhoto,
FaceSubject,
ArchivePhotoFace,
ContentEvent,
Recording,
RecordingMedia,

View file

@ -1,23 +1,13 @@
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 type { Payload, PayloadRequest } 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 { FACE_SEARCH_MODEL, readFaceVector } from '@/lib/faceSearch'
import { messageOf } from '@/utilities/media/ffmpeg'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import {
@ -26,18 +16,19 @@ import {
} 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.
* C242 — the batch owner of the anonymous face index (`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 PERSISTED as
* identity-free rows in `archivePhotoFace` (scope B, decision of 2026-10-01:
* any visitor can find themselves). One photo's rows are replaced atomically —
* never merged with a previous revision.
*
* 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.
* the model that produced the stored descriptors, so a model change marks every
* photo stale and the next run recomputes. The batch's own writes never change
* the key (`context.faceIndex` skips the catalog derivation and the marker
* write does not read it back).
*
* A failure of one photo is returned, never thrown: nothing is written, the
* next run retries it and the receipt names the stage.
@ -46,7 +37,7 @@ import {
/** Long edge sent to the engine; the original never leaves the archive. */
const IMAGE_MAX_EDGE = 1024
export type ArchivePhotoFaceImage = {
type ArchivePhotoFaceImage = {
pixels: Buffer
width: number
height: number
@ -57,10 +48,10 @@ export type ArchivePhotoFaceAnalyzer = (image: ArchivePhotoFaceImage) => Promise
/**
* 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 exact input both the batch and the browser compute descriptors over, so
* the selfie and the archive enter the same space.
*/
export const prepareFaceImage = async (filePath: string): Promise<ArchivePhotoFaceImage> => {
const prepareFaceImage = async (filePath: string): Promise<ArchivePhotoFaceImage> => {
const prepared = await sharp(filePath)
.rotate()
.resize({ width: IMAGE_MAX_EDGE, height: IMAGE_MAX_EDGE, fit: 'inside' })
@ -80,8 +71,6 @@ export type ArchivePhotoFaceIndexItem = {
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
@ -90,39 +79,17 @@ export type ArchivePhotoFaceIndexQueue = {
}
export type ArchivePhotoFaceIndexResult =
| { status: 'indexed'; matchedSubjects: number[] }
| { status: 'indexed'; descriptorCount: 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).
* The batch queue: the approved photos with their marker 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,
@ -133,8 +100,7 @@ export const listArchivePhotoFaceIndexQueue = async ({
refresh?: boolean
limit?: number
}): Promise<ArchivePhotoFaceIndexQueue> => {
const subjects = await listFaceSubjects(payload)
const indexKey = facePhotoIndexKey(subjects)
const indexKey = FACE_SEARCH_MODEL
const photos = await payload.find({
collection: ARCHIVE_PHOTO_SLUG,
@ -157,7 +123,6 @@ export const listArchivePhotoFaceIndexQueue = async ({
return {
indexKey,
eligibleSubjects: subjects.filter(isIndexableSubject).length,
totalApproved: photos.docs.length,
stale: staleRows.length,
items: limit && limit > 0 ? staleRows.slice(0, limit) : staleRows,
@ -165,10 +130,9 @@ export const listArchivePhotoFaceIndexQueue = async ({
}
/**
* 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).
* One photo: download → prepare → analyze → transactional descriptor replace +
* marker. The rows of the previous revision are deleted inside the same
* transaction, so a query never sees a half-replaced photo.
*/
export const indexArchivePhotoFaces = async ({
payload,
@ -214,13 +178,13 @@ export const indexArchivePhotoFaces = async ({
}
try {
const matchedSubjects = await writeArchivePhotoFaceMatches({
const descriptorCount = await writeArchivePhotoFaceDescriptors({
payload,
photoId: item.id,
descriptors,
indexKey,
})
return { status: 'indexed', matchedSubjects }
return { status: 'indexed', descriptorCount }
} catch (error) {
return { status: 'failed', stage: 'write', error: messageOf(error, 'erro desconhecido') }
}
@ -230,11 +194,12 @@ export const indexArchivePhotoFaces = async ({
}
/**
* 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.
* Replaces the descriptor rows of one photo and stamps the marker — one
* transaction, so a concurrent takedown or search never meets a half-written
* photo. Invalid descriptors are dropped (fail-closed) instead of poisoning the
* index.
*/
const writeArchivePhotoFaceMatches = async ({
export const writeArchivePhotoFaceDescriptors = async ({
payload,
photoId,
descriptors,
@ -242,35 +207,35 @@ const writeArchivePhotoFaceMatches = async ({
}: {
payload: Payload
photoId: number
descriptors: number[][]
descriptors: readonly number[][]
indexKey: string
}): Promise<number[]> =>
withPayloadTransaction(payload, async ({ req }) => {
const subjects = await listFaceSubjects(payload, req)
const matchedSubjects: number[] = []
}): Promise<number> => {
const vectors = descriptors
.map((descriptor) => readFaceVector(descriptor))
.filter((vector): vector is number[] => vector !== null)
for (const subject of subjects) {
if (!isIndexableSubject(subject)) continue
const write = async (writeReq?: { transactionID?: number | string }) => {
await payload.delete({
collection: 'archivePhotoFace',
where: { photo: { equals: photoId } },
depth: 0,
req: writeReq,
// Intentional bypass: the batch CLI is a trusted operator with no session.
overrideAccess: true,
})
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 },
for (const vector of vectors) {
await payload.create({
collection: 'archivePhotoFace',
data: {
photo: photoId,
model: indexKey,
detectedAt: new Date().toISOString(),
vector,
},
depth: 0,
req,
// Intentional bypass: the batch CLI is a trusted operator with no session.
req: writeReq,
// Intentional bypass: same trusted operator.
overrideAccess: true,
})
}
@ -280,11 +245,39 @@ const writeArchivePhotoFaceMatches = async ({
id: photoId,
data: { faces: { checkedAt: new Date().toISOString(), checkedKey: indexKey } },
depth: 0,
req,
req: writeReq,
context: { faceIndex: true },
// Intentional bypass: the batch CLI is a trusted operator with no session.
// Intentional bypass: same trusted operator.
overrideAccess: true,
})
}
return matchedSubjects
return withPayloadTransaction(payload, async ({ req }) => {
await write(req)
return vectors.length
})
}
/**
* Purges every descriptor row of one photo. Called by the photo lifecycle hooks
* when a photo stops being `approved` (unapproval/removal) and on deletion — the
* DB-level FK cascade covers direct SQL deletes, this covers the Local API path.
*/
export const purgeFaceDescriptorsForPhoto = async ({
payload,
photoId,
req,
}: {
payload: Payload
photoId: number
req?: PayloadRequest
}): Promise<void> => {
await payload.delete({
collection: 'archivePhotoFace',
where: { photo: { equals: photoId } },
depth: 0,
req,
// Intentional bypass: lifecycle maintenance, never an actor-bound write.
overrideAccess: true,
})
}

View file

@ -0,0 +1,158 @@
import 'server-only'
import type { Payload } from 'payload'
import {
FACE_SEARCH_MAX_DISTANCE,
FACE_SEARCH_MODEL,
faceEuclideanDistance,
findFaceDescriptorPhotoIds,
readFaceVector,
type FaceDescriptorEntry,
} from '@/lib/faceSearch'
/**
* C242 — reads of the anonymous face index. The collection is admin-only and
* machine-written, so every read is the Local API without a user (the same
* anonymous-read bypass precedent as `archivePhotoReads`): the SELECTOR is the
* search request itself, never an actor.
*
* The full set is loaded once per short window into an in-process cache
* (`Float32Array` descriptors): the ranking is exact and linear — no pgvector
* (C229 precedent) — and the TTL keeps an opt-out or a new index run visible
* without a deploy. The opt-out path never uses the cache: it re-reads and
* deletes in the same pass, because "leave-index" must take effect immediately.
*/
/** How long a loaded index answers before a re-read; short on purpose. */
const INDEX_CACHE_TTL_MS = 60_000
/**
* Defensive ceiling: the flat cache costs ~600 bytes per face, so half a
* million faces would be ~300 MB. Past the cap the search fails closed rather
* than answering from a truncated index. The real archive (~6.5k photos) sits
* orders of magnitude below it.
*/
const MAX_INDEXED_DESCRIPTORS = 300_000
type FaceDescriptorCache = {
loadedAt: number
entries: FaceDescriptorEntry[]
}
let descriptorCache: FaceDescriptorCache | null = null
/** Test seam: a fresh read after a fixture write in the same process. */
export const invalidateFaceDescriptorIndex = (): void => {
descriptorCache = null
}
const relationId = (value: unknown): number | null => {
if (typeof value === 'number') return value
if (value && typeof value === 'object' && 'id' in value && typeof value.id === 'number') {
return value.id
}
return null
}
const loadFaceDescriptorIndexFromDb = async (payload: Payload): Promise<FaceDescriptorEntry[]> => {
const result = await payload.find({
collection: 'archivePhotoFace',
where: { model: { equals: FACE_SEARCH_MODEL } },
depth: 0,
limit: 0,
pagination: false,
select: { photo: 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,
})
if (result.docs.length > MAX_INDEXED_DESCRIPTORS) {
throw new Error(
`Índice facial acima do teto defensivo (${result.docs.length} > ${MAX_INDEXED_DESCRIPTORS}).`,
)
}
const entries: FaceDescriptorEntry[] = []
for (const doc of result.docs) {
const photoId = relationId(doc.photo)
const vector = readFaceVector(doc.vector)
if (photoId === null || vector === null) continue
entries.push({ photoId, vector: Float32Array.from(vector) })
}
return entries
}
/** The current index, from the short-lived in-process cache when fresh. */
export const loadFaceDescriptorIndex = async (payload: Payload): Promise<FaceDescriptorEntry[]> => {
const now = Date.now()
if (descriptorCache && now - descriptorCache.loadedAt < INDEX_CACHE_TTL_MS) {
return descriptorCache.entries
}
const entries = await loadFaceDescriptorIndexFromDb(payload)
descriptorCache = { loadedAt: now, entries }
return entries
}
/**
* The distinct approved-photo ids the query vector matches, in index order.
* Pure math on the loaded index; the caller intersects with the approved album
* read (draft/removed never leave this function as a result).
*/
export const findMatchedFacePhotoIds = ({
vector,
entries,
}: {
vector: ArrayLike<number>
entries: readonly FaceDescriptorEntry[]
}): number[] => findFaceDescriptorPhotoIds(vector, entries)
/**
* Opt-out ("leave-index"): the person's own descriptor is the authorization —
* every indexed face under the threshold is deleted and the person stops being
* found. Reads and deletes in one pass, never from the cache; returns how many
* faces were erased (never which photos: the answer is not a score nor a list
* of third-party places).
*/
export const deleteFaceDescriptorMatches = async ({
payload,
vector,
}: {
payload: Payload
vector: ArrayLike<number>
}): Promise<number> => {
const result = await payload.find({
collection: 'archivePhotoFace',
where: { model: { equals: FACE_SEARCH_MODEL } },
depth: 0,
limit: 0,
pagination: false,
select: { vector: true },
// Intentional bypass: same anonymous-request rationale as the load above;
// the deletion itself is authorized by the matched descriptor.
overrideAccess: true,
})
const matchedIds: number[] = []
for (const doc of result.docs) {
const stored = readFaceVector(doc.vector)
if (!stored) continue
if (faceEuclideanDistance(vector, stored) < FACE_SEARCH_MAX_DISTANCE) {
matchedIds.push(doc.id)
}
}
if (matchedIds.length === 0) return 0
await payload.delete({
collection: 'archivePhotoFace',
where: { id: { in: matchedIds } },
depth: 0,
// Intentional bypass: the search endpoint is anonymous and the removal is
// authorized by the matched descriptor, never by a session.
overrideAccess: true,
})
invalidateFaceDescriptorIndex()
return matchedIds.length
}

View file

@ -1,102 +0,0 @@
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

@ -1,136 +0,0 @@
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

@ -8,24 +8,24 @@ import {
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.
* C242 — the selfie search over real HTTP: the entry gated by the album flag,
* the Consent fail-closed, the scope B answer (anonymous descriptors of
* approved photos only, never a third-party name), the honest empty, the
* opt-out by matched descriptor and the homepage discovery section (C243). The
* engine is the deterministic stub (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`): the
* fixture bytes derive the descriptor the seeded face rows carry, 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`).
* process runs the real hooks. Serial: this spec owns its archivePhotoFace/
* 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'
@ -34,7 +34,6 @@ 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)
@ -134,38 +133,36 @@ const ensureConsent = async (
return { id: doc.id, text: doc.text }
}
const createSubject = async (
/** One anonymous descriptor row, exactly like the batch writes it. */
const createFaceRow = async (
request: APIRequestContext,
headers: Headers,
data: { vector: number[]; consentId: number; consentHash: string; matchedPhotos: number[] },
data: { photo: number; vector: number[] },
): Promise<number> => {
const response = await request.post(`${BASE_URL}/api/faceSubject`, {
const response = await request.post(`${BASE_URL}/api/archivePhotoFace`, {
headers,
data: {
label: `Pessoa de teste ${uniqueMarker()}`,
consent: data.consentId,
consentHash: data.consentHash,
photo: data.photo,
model: FACE_SEARCH_MODEL,
detectedAt: new Date().toISOString(),
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
return ((await response.json()) as { doc: { id: number } }).doc.id
}
const faceSubjectById = async (
const faceRowsOf = 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 })
photoId: number,
): Promise<number> => {
const response = await request.get(
`${BASE_URL}/api/archivePhotoFace?limit=0&depth=0&where[photo][equals]=${photoId}`,
{ headers },
)
expect(response.ok(), await response.text()).toBeTruthy()
return (await response.json()) as { status: string; vector: unknown }
return ((await response.json()) as { totalDocs: number }).totalDocs
}
/** Consent card → pick screen with the given fixture, ready for the primary action. */
@ -183,11 +180,8 @@ const reachPickWithFixture = async (
test.describe.configure({ mode: 'serial' })
test.describe('Frontend busca por selfie (C234)', () => {
test.describe('Frontend busca por selfie (C242)', () => {
let headers: Headers
let indexConsent: { id: number; text: unknown }
let indexConsentHash: string
let subjectId: number
let linkedPhoto: number
let draftLinkedPhoto: number
let otherPhoto: number
@ -213,8 +207,7 @@ test.describe('Frontend busca por selfie (C234)', () => {
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 ensureConsent(request, headers, FACE_INDEX_CONSENT_KEY)
await setAlbum(request, headers, {
published: true,
@ -230,22 +223,16 @@ test.describe('Frontend busca por selfie (C234)', () => {
data: { publicationStatus: 'draft' },
})
subjectId = await createSubject(request, headers, {
vector: faceStubDescriptorFromBytes(new Uint8Array(selfieA.buffer)),
consentId: indexConsent.id,
consentHash: indexConsentHash,
matchedPhotos: [linkedPhoto, draftLinkedPhoto],
})
const descriptor = faceStubDescriptorFromBytes(new Uint8Array(selfieA.buffer))
await createFaceRow(request, headers, { photo: linkedPhoto, vector: descriptor })
// The draft row is seeded by hand on purpose: the search must intersect
// the index with the approved public read and never surface it.
await createFaceRow(request, headers, { photo: draftLinkedPhoto, vector: descriptor })
})
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 })
@ -295,6 +282,23 @@ test.describe('Frontend busca por selfie (C234)', () => {
await expect(primary).toBeEnabled()
})
test('shows the discovery section on the homepage while the search is open (C243)', async ({
page,
}) => {
await page.goto('/')
await waitForStreamSettled(page)
const section = page.getByRole('region', { name: 'Encontre você nas fotos' })
await expect(section).toBeVisible()
await expect(
section.getByRole('link', { name: 'Encontrar minhas fotos' }).first(),
).toHaveAttribute('href', '/fotos/encontre')
await expect(section.getByRole('link', { name: 'Ver o álbum' }).first()).toHaveAttribute(
'href',
'/fotos',
)
})
test('finds only the approved photos of the matched subject, without third-party names', async ({
page,
}) => {
@ -373,11 +377,13 @@ test.describe('Frontend busca por selfie (C234)', () => {
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()
await expect(
page.getByRole('heading', { name: 'Pronto — seu rosto saiu do índice' }),
).toBeVisible()
const subject = await faceSubjectById(request, headers, subjectId)
expect(subject.status).toBe('removed')
expect(subject.vector).toBeNull()
// The person's face rows are gone from the index (the search stops
// answering); the photos themselves stay in the album.
expect(await faceRowsOf(request, headers, linkedPhoto)).toBe(0)
})
test('fails the engine honestly when the local processing breaks', async ({ page }) => {
@ -429,6 +435,11 @@ test.describe('Frontend busca por selfie (C234)', () => {
await waitForStreamSettled(page)
await expect(page.getByRole('link', { name: 'Começar busca por selfie' })).toHaveCount(0)
// C243 — closed leaves no dead CTA on the homepage either.
await page.goto('/')
await waitForStreamSettled(page)
await expect(page.getByRole('region', { name: 'Encontre você nas fotos' })).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).

View file

@ -5,25 +5,21 @@ 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 { FACE_SEARCH_DESCRIPTOR_LENGTH, FACE_SEARCH_MODEL } from '@/lib/faceSearch'
import { writeArchivePhotoFaceDescriptors } from '@/utilities/faceIndex/faceDescriptorIndex'
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.
* C242 — the fixtures shared by the face-search int specs: the file-backed
* archive photo over the real Payload test database and the indexed face rows
* (scope B: anonymous descriptors, no subjects). The specs serialize on the
* same lease because the in-process descriptor cache is derived state.
*/
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 FACE_SPECS_LEASE_KEY = 'c240-face-specs'
export const FACE_INDEX_LEASE_KEY = 'c240-face-index-consent'
export const FACE_SEARCH_LEASE_KEY = 'c240-face-search-consent'
export const faceTestVector = (value: number): number[] =>
Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => value)
@ -86,27 +82,24 @@ export const createFaceTestPhoto = async ({
return { id: created.id, flickrId }
}
/** Enrolls a subject with the current index Consent resolved by stable key. */
export const enrollFaceTestSubject = async ({
/**
* Seeds the descriptor rows of one photo exactly like the batch does (rows +
* marker, one transaction). `model` lets a spec pin the old-model contract.
*/
export const indexFaceTestPhoto = async ({
payload,
label,
vector,
trackedSubjectIds,
photoId,
descriptors,
model = FACE_SEARCH_MODEL,
}: {
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({
photoId: number
descriptors: number[][]
model?: string
}): Promise<number> =>
writeArchivePhotoFaceDescriptors({
payload,
label,
descriptor: vector,
consent: { id: consent.id, contentHash: consent.contentHash },
photoId,
descriptors,
indexKey: model,
})
trackedSubjectIds.add(enrolled.id)
return enrolled.id
}

View file

@ -0,0 +1,247 @@
// @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_SEARCH_MODEL } from '@/lib/faceSearch'
import config from '@/payload.config'
import {
indexArchivePhotoFaces,
listArchivePhotoFaceIndexQueue,
purgeFaceDescriptorsForPhoto,
writeArchivePhotoFaceDescriptors,
} from '@/utilities/faceIndex/faceDescriptorIndex'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import {
createFaceTestPhoto,
FACE_SPECS_LEASE_KEY,
faceTestVector,
} from '../helpers/faceIntFixtures'
import {
acquireTestDatabaseLease,
PHOTO_ALBUM_LEASE_KEY,
withExclusiveTestDatabaseLease,
} from '../helpers/testDatabaseLease'
// C242 — the anonymous index boundary over the real Payload `teqo_test`: the
// batch's transactional descriptor replacement, the marker that makes the run
// resumable and the purges that keep draft/removed photos out of the search.
let payload: Payload
const createdFlickrIds = new Set<string>()
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: 'c240',
trackedFlickrIds: createdFlickrIds,
tempDirs,
})
const faceRowsOf = async (photoId: number): Promise<number> => {
const result = await payload.count({
collection: 'archivePhotoFace',
where: { photo: { equals: photoId } },
overrideAccess: true,
})
return result.totalDocs
}
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 ?? {}
}
const queueItem = async (
id: number,
refresh = false,
): Promise<
Awaited<ReturnType<typeof listArchivePhotoFaceIndexQueue>>['items'][number] | undefined
> => {
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh })
return queue.items.find((item) => item.id === id)
}
describe('anonymous face descriptor index (C242)', () => {
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
beforeAll(async () => {
payload = await getPayload({ config: await config })
specsLease = await acquireTestDatabaseLease(payload, FACE_SPECS_LEASE_KEY)
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true, removalChannelUrl: CHANNEL, selfieSearchEnabled: false },
overrideAccess: true,
}),
)
})
afterAll(async () => {
for (const flickrId of createdFlickrIds) {
await payload.delete({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
overrideAccess: true,
})
}
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
await specsLease.release()
})
it('lists only approved photos as stale and stamps the marker after indexing', async () => {
const approved = await createPhoto(true)
await createPhoto(false)
const before = await queueItem(approved.id)
expect(before).toBeDefined()
expect(before?.checkedKey).toBeNull()
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
const result = await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === approved.id)!,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0), faceTestVector(0.1)],
})
expect(result).toEqual({ status: 'indexed', descriptorCount: 2 })
expect(await faceRowsOf(approved.id)).toBe(2)
const marker = await markerOf(approved.id)
expect(marker.checkedKey).toBe(FACE_SEARCH_MODEL)
expect(typeof marker.checkedAt).toBe('string')
// The marker makes the next run a no-op for this photo.
expect(await queueItem(approved.id)).toBeUndefined()
})
it('replaces the rows of a photo on re-index instead of merging revisions', async () => {
const photo = await createPhoto(true)
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
const item = queue.items.find((entry) => entry.id === photo.id)!
await indexArchivePhotoFaces({
payload,
item,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0), faceTestVector(0.2), faceTestVector(0.3)],
})
expect(await faceRowsOf(photo.id)).toBe(3)
await indexArchivePhotoFaces({
payload,
item,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0.4)],
})
expect(await faceRowsOf(photo.id)).toBe(1)
})
it('drops malformed descriptors (fail-closed) and still stamps the marker', async () => {
const photo = await createPhoto(true)
const written = await writeArchivePhotoFaceDescriptors({
payload,
photoId: photo.id,
descriptors: [
[1, 2, 3],
[Number.NaN, ...faceTestVector(0).slice(1)],
],
indexKey: FACE_SEARCH_MODEL,
})
expect(written).toBe(0)
expect(await faceRowsOf(photo.id)).toBe(0)
expect((await markerOf(photo.id)).checkedKey).toBe(FACE_SEARCH_MODEL)
expect(await queueItem(photo.id)).toBeUndefined()
})
it('purges the descriptor rows when the photo stops being approved', async () => {
const photo = await createPhoto(true)
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 faceRowsOf(photo.id)).toBe(1)
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: photo.id,
data: { publicationStatus: 'removed' },
overrideAccess: true,
})
expect(await faceRowsOf(photo.id)).toBe(0)
expect(await queueItem(photo.id, true)).toBeUndefined()
})
it('purges the descriptor rows on deletion and on the explicit maintenance call', async () => {
const photo = await createPhoto(true)
await writeArchivePhotoFaceDescriptors({
payload,
photoId: photo.id,
descriptors: [faceTestVector(0.5)],
indexKey: FACE_SEARCH_MODEL,
})
expect(await faceRowsOf(photo.id)).toBe(1)
await purgeFaceDescriptorsForPhoto({ payload, photoId: photo.id })
expect(await faceRowsOf(photo.id)).toBe(0)
await writeArchivePhotoFaceDescriptors({
payload,
photoId: photo.id,
descriptors: [faceTestVector(0.5)],
indexKey: FACE_SEARCH_MODEL,
})
await payload.delete({ collection: ARCHIVE_PHOTO_SLUG, id: photo.id, overrideAccess: true })
const orphans = await payload.count({
collection: 'archivePhotoFace',
where: { photo: { equals: photo.id } },
overrideAccess: true,
})
expect(orphans.totalDocs).toBe(0)
})
it('reports an indexed photo with zero faces honestly', async () => {
const photo = await createPhoto(true)
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
const result = await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photo.id)!,
indexKey: queue.indexKey,
analyze: async () => [],
})
expect(result).toEqual({ status: 'indexed', descriptorCount: 0 })
expect(await faceRowsOf(photo.id)).toBe(0)
expect((await markerOf(photo.id)).checkedKey).toBe(FACE_SEARCH_MODEL)
})
})

View file

@ -14,21 +14,18 @@ vi.mock('next/cache', () => ({
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 { FACE_SEARCH_MODEL } from '@/lib/faceSearch'
import config from '@/payload.config'
import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment'
import {
indexArchivePhotoFaces,
listArchivePhotoFaceIndexQueue,
} from '@/utilities/faceSubjects/faceSubjectPhotoIndex'
import { invalidateFaceDescriptorIndex } from '@/utilities/faceIndex/faceDescriptorReads'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import {
createFaceTestPhoto,
enrollFaceTestSubject,
FACE_INDEX_LEASE_KEY,
FACE_SEARCH_LEASE_KEY,
FACE_SPECS_LEASE_KEY,
faceTestVector,
indexFaceTestPhoto,
} from '../helpers/faceIntFixtures'
import {
acquireTestDatabaseLease,
@ -37,14 +34,14 @@ import {
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.
// C242 — the anonymous selfie endpoint over the real Payload `teqo_test`:
// the kill switch and the two Consent fail-closed layers, the scope B answer
// (anonymous descriptors of approved photos only, 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({
@ -60,25 +57,11 @@ const createPhoto = (approved: boolean) =>
createFaceTestPhoto({
payload,
approved,
prefix: 'c234-api',
prefix: 'c240-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({
@ -98,7 +81,7 @@ const post = (body: unknown, headers: Record<string, string> = {}): Promise<Resp
}),
)
describe('selfie search endpoint (C234)', () => {
describe('selfie search endpoint (C242)', () => {
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
beforeAll(async () => {
@ -123,10 +106,8 @@ describe('selfie search endpoint (C234)', () => {
overrideAccess: true,
})
}
for (const id of createdSubjectIds) {
await payload.delete({ collection: 'faceSubject', id, overrideAccess: true })
}
await setSelfieSearchEnabled(false)
invalidateFaceDescriptorIndex()
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
await specsLease.release()
})
@ -165,43 +146,51 @@ describe('selfie search endpoint (C234)', () => {
await setSelfieSearchEnabled(false)
})
it('fails closed (503) while the query Consent is not configured', async () => {
it('fails closed (503) while either 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 {
const withoutQuery = await post({ vector: faceTestVector(0), intent: 'search' })
expect(withoutQuery.status).toBe(503)
await expect(withoutQuery.json()).resolves.toEqual({ ok: false, error: 'consent' })
await ensureLeasedConsent(payload, {
consentKey: FACE_SEARCH_CONSENT_KEY,
leaseKey: FACE_SEARCH_LEASE_KEY,
})
await payload.delete({
collection: 'consent',
where: { key: { equals: FACE_INDEX_CONSENT_KEY } },
overrideAccess: true,
})
const withoutNotice = await post({ vector: faceTestVector(0), intent: 'search' })
expect(withoutNotice.status).toBe(503)
} finally {
await ensureLeasedConsent(payload, {
consentKey: FACE_INDEX_CONSENT_KEY,
leaseKey: FACE_INDEX_LEASE_KEY,
})
await setSelfieSearchEnabled(false)
}
})
it('returns only the approved photos of the matched subject, without names or score', async () => {
it('returns only the approved photos with a matching face, 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] },
await indexFaceTestPhoto({ payload, photoId: approved.id, descriptors: [vector] })
// A draft with descriptor rows by hand must never surface: the endpoint
// intersects the index with the approved public read.
await payload.create({
collection: 'archivePhotoFace',
data: { photo: draft.id, model: FACE_SEARCH_MODEL, vector },
overrideAccess: true,
})
invalidateFaceDescriptorIndex()
await setSelfieSearchEnabled(true)
const response = await post({ vector, intent: 'search' })
@ -217,19 +206,18 @@ describe('selfie search endpoint (C234)', () => {
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.
// A face nobody has 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 indexFaceTestPhoto({ payload, photoId: approved.id, descriptors: [vector] })
invalidateFaceDescriptorIndex()
await setSelfieSearchEnabled(true)
const before = await post({ vector, intent: 'search' })
@ -238,14 +226,12 @@ describe('selfie search endpoint (C234)', () => {
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,
const rows = await payload.count({
collection: 'archivePhotoFace',
where: { photo: { equals: approved.id } },
overrideAccess: true,
})
expect(stored.status).toBe('removed')
expect(stored.vector).toBeNull()
expect(rows.totalDocs).toBe(0)
const after = await post({ vector, intent: 'search' })
await expect(after.json()).resolves.toEqual({ ok: true, found: false })
@ -270,13 +256,14 @@ describe('selfie search endpoint (C234)', () => {
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,
const approved = await createPhoto(true)
await indexFaceTestPhoto({
payload,
photoId: approved.id,
descriptors: [vector],
model: 'outro-modelo@0',
})
invalidateFaceDescriptorIndex()
await setSelfieSearchEnabled(true)
const response = await post({ vector, intent: 'search' })

View file

@ -1,249 +0,0 @@
// @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

@ -0,0 +1,77 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
ARCHIVE_PUBLISH_DEFAULT_OUT_DIR,
archivePublishReportStamp,
formatArchivePublishReport,
parseArchivePublishCliArgs,
summarizeArchivePublishResults,
} from '../../scripts/lib/archivePublishPlan.mjs'
// C242 — the pure parser/summary of `pnpm archive:publish`: the dry-run default
// and the honest per-photo failure accounting of the bulk approval.
describe('parseArchivePublishCliArgs', () => {
it('defaults to the dry-run and parses the options', () => {
expect(parseArchivePublishCliArgs([])).toEqual({
apply: false,
limit: null,
out: ARCHIVE_PUBLISH_DEFAULT_OUT_DIR,
help: false,
})
const apply = parseArchivePublishCliArgs(['--apply', '--limit', '20', '--out', 'x'])
expect(apply.apply).toBe(true)
expect(apply.limit).toBe(20)
expect(apply.out).toBe('x')
expect(parseArchivePublishCliArgs(['--help']).help).toBe(true)
})
it('fails closed on a bad limit, escaping out and unknown args', () => {
expect(() => parseArchivePublishCliArgs(['--limit', '0'])).toThrow(/--limit/)
expect(() => parseArchivePublishCliArgs(['--limit', 'x'])).toThrow(/--limit/)
expect(() => parseArchivePublishCliArgs(['--out', '../etc'])).toThrow(/--out/)
expect(() => parseArchivePublishCliArgs(['--apply=1'])).toThrow(/argumento desconhecido/)
expect(() => parseArchivePublishCliArgs(['--limit'])).toThrow(/faltou valor/)
})
})
describe('summarizeArchivePublishResults', () => {
it('counts approvals and lists failures with the reason', () => {
const summary = summarizeArchivePublishResults([
{ photoId: 1, status: 'approved' },
{ photoId: 2, status: 'approved' },
{ photoId: 3, status: 'failed', error: 'canal ausente' },
])
expect(summary.approved).toBe(2)
expect(summary.failed).toBe(1)
expect(summary.failures).toEqual([{ photoId: 3, error: 'canal ausente' }])
})
})
describe('formatting', () => {
it('stamps the receipt name and prints the apply summary', () => {
expect(archivePublishReportStamp('2026-10-01T04:09:04.123Z')).toBe('2026-10-01T04-09-04-123Z')
const plan = formatArchivePublishReport({
mode: 'plan',
target: 'host/db',
queue: { totalDrafts: 6492, items: 6492 },
summary: { approved: 0, failed: 0, failures: [] },
}).join('\n')
expect(plan).toContain('modo: plan')
expect(plan).toContain('drafts elegíveis: 6492')
const apply = formatArchivePublishReport({
mode: 'apply',
target: 'host/db',
queue: { totalDrafts: 10, items: 3 },
summary: { approved: 2, failed: 1, failures: [{ photoId: 7, error: 'sem arquivo' }] },
}).join('\n')
expect(apply).toContain('aprovadas: 2 | falharam: 1')
expect(apply).toContain('✗ foto 7: sem arquivo')
})
})

View file

@ -64,11 +64,25 @@ vi.mock('@/utilities/content/contentPieceReads', () => ({
getPublishedContentPieceItems: async () => contentPieces.items,
}))
// C233 — the footer's "Fotos" discovery flag reads the approved archive photos
// through the Payload DB; the unit env has no database and the real listing is
// e2e-covered (frontendFotos.e2e.spec.ts).
// C233/C243 — the footer's "Fotos" discovery flag and the selfie-search section
// read the approved archive photos and the photoAlbum global through the
// Payload DB; the unit env has no database and the real gate is e2e-covered
// (frontendFotosSelfie.e2e.spec.ts).
const homePhotoAlbum = vi.hoisted(() => ({
hasPhotos: false,
selfieSearchEnabled: false,
published: true,
}))
vi.mock('@/utilities/archivePhotos/archivePhotoReads', () => ({
hasPublishedArchivePhotos: async () => false,
hasPublishedArchivePhotos: async () => homePhotoAlbum.hasPhotos,
}))
vi.mock('@/utilities/globalReads', () => ({
getCachedGlobal: () => async () => ({
published: homePhotoAlbum.published,
selfieSearchEnabled: homePhotoAlbum.selfieSearchEnabled,
}),
}))
// S14 — the card section renders the client studio island (next/font local
@ -84,6 +98,9 @@ vi.mock('@/components/cards/CardsStudio', () => ({
afterEach(() => {
contentPieces.items = []
homePhotoAlbum.hasPhotos = false
homePhotoAlbum.selfieSearchEnabled = false
homePhotoAlbum.published = true
cleanup()
})
@ -144,4 +161,29 @@ describe('Campaign home', () => {
expect(screen.queryByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeNull()
expect(screen.queryByRole('link', { name: /Ver todas as peças/ })).toBeNull()
})
it('mostra a seção da busca por selfie só quando a busca está aberta (C243, fail-closed)', async () => {
homePhotoAlbum.hasPhotos = true
homePhotoAlbum.selfieSearchEnabled = true
render(await HomePage())
expect(screen.getByRole('heading', { name: 'Encontre você nas fotos' })).toBeTruthy()
const ctas = screen.getAllByRole('link', { name: /Encontrar minhas fotos/ })
expect(ctas.length).toBeGreaterThan(0)
for (const cta of ctas) expect(cta.getAttribute('href')).toBe('/fotos/encontre')
cleanup()
homePhotoAlbum.selfieSearchEnabled = false
render(await HomePage())
expect(screen.queryByRole('heading', { name: 'Encontre você nas fotos' })).toBeNull()
cleanup()
// Flag on but no approved photo is still closed: no dead CTA.
homePhotoAlbum.selfieSearchEnabled = true
homePhotoAlbum.hasPhotos = false
render(await HomePage())
expect(screen.queryByRole('heading', { name: 'Encontre você nas fotos' })).toBeNull()
})
})

View file

@ -5,12 +5,12 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
// C234 — the write guards of the two face CLIs, exercised as subprocesses:
// C242 — the write guards of the face/publish 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 publishScript = join(repoRoot, 'scripts', 'publish-archive-photos.mjs')
const run = (scriptPath: string, args: string[], env: Record<string, string> = {}) =>
spawnSync(process.execPath, [scriptPath, ...args], {
@ -26,8 +26,8 @@ const run = (scriptPath: string, args: string[], env: Record<string, string> = {
PAYLOAD_SECRET: 'test-secret',
TEQO_ENV: '',
ALLOW_REMOTE_DB: '',
FACE_ENROLL_CONFIRM: '',
FACE_INDEX_CONFIRM: '',
ARCHIVE_PUBLISH_CONFIRM: '',
S3_BUCKET: '',
S3_ENDPOINT: '',
S3_ACCESS_KEY_ID: '',
@ -44,53 +44,7 @@ const withS3 = {
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)', () => {
describe('faces:index write guards (C242)', () => {
it('prints the help even without a database', () => {
const result = run(indexScript, ['--help'], { DATABASE_URL: '' })
@ -138,3 +92,37 @@ describe('faces:index write guards (C234)', () => {
expect(output(result)).not.toContain('TEQO_ENV=')
}, 30_000)
})
describe('archive:publish write guards (C242)', () => {
it('prints the help even without a database', () => {
const result = run(publishScript, ['--help'], { DATABASE_URL: '' })
expect(result.status).toBe(0)
expect(result.stdout).toContain('ARCHIVE_PUBLISH_CONFIRM')
expect(result.stdout).toContain('--limit')
})
it('refuses --apply on a production target without the intent flag', () => {
const result = run(publishScript, ['--apply'])
expect(result.status).toBe(1)
expect(output(result)).toContain('ARCHIVE_PUBLISH_CONFIRM=1')
})
it('requires the declared TEQO_ENV once the intent flag is set', () => {
const result = run(publishScript, ['--apply'], { ARCHIVE_PUBLISH_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(publishScript, ['--apply'], {
ARCHIVE_PUBLISH_CONFIRM: '1',
TEQO_ENV: 'staging',
})
expect(result.status).toBe(1)
expect(output(result)).toContain('≠ "teqo_staging"')
})
})

View file

@ -0,0 +1,60 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
buildConsentRichText,
FACE_INDEX_CONSENT_PARAGRAPHS,
FACE_INDEX_CONSENT_TEXT,
FACE_SEARCH_CONSENT_PARAGRAPHS,
FACE_SEARCH_CONSENT_TEXT,
} from '../../scripts/lib/faceConsentTexts.mjs'
// C242 — the versioned legal texts of the selfie search: the Lexical shape the
// Consent collection stores and the honest content the visitor reads.
describe('buildConsentRichText', () => {
it('builds the Lexical root with one paragraph per text', () => {
const rich = buildConsentRichText(['Um.', 'Dois.'])
expect(rich.root.type).toBe('root')
expect(rich.root.children).toHaveLength(2)
expect(rich.root.children[0].children[0].text).toBe('Um.')
expect(rich.root.children[1].children[0].text).toBe('Dois.')
})
it('produces an empty root for no paragraphs (fail-closed content)', () => {
expect(buildConsentRichText([]).root.children).toEqual([])
})
})
const flatten = (text: unknown): string =>
(text as { root: { children: { children: { text: string }[] }[] } }).root.children
.map((paragraph) => paragraph.children.map((child) => child.text).join(''))
.join('\n')
describe('face consent texts', () => {
it('has substantive paragraphs in both texts', () => {
expect(FACE_SEARCH_CONSENT_PARAGRAPHS.length).toBeGreaterThanOrEqual(4)
expect(FACE_INDEX_CONSENT_PARAGRAPHS.length).toBeGreaterThanOrEqual(4)
for (const paragraph of [...FACE_SEARCH_CONSENT_PARAGRAPHS, ...FACE_INDEX_CONSENT_PARAGRAPHS]) {
expect(paragraph.trim().length).toBeGreaterThan(40)
}
})
it('states the on-device processing and the LGPD basis in the query consent', () => {
const text = flatten(FACE_SEARCH_CONSENT_TEXT)
expect(text).toMatch(/aparelho/i)
expect(text).toMatch(/não é enviada nem armazenada/i)
expect(text).toMatch(/LGPD/)
expect(text).toMatch(/retirar este consentimento/i)
})
it('states the anonymous index and the self-service opt-out in the notice', () => {
const text = flatten(FACE_INDEX_CONSENT_TEXT)
expect(text).toMatch(/índice biométrico anônimo/i)
expect(text).toMatch(/sem nome/i)
expect(text).toMatch(/Minha presença/)
expect(text).toMatch(/remoção de uma foto/i)
})
})

View file

@ -1,128 +0,0 @@
// @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

@ -12,8 +12,8 @@ import {
summarizeFaceIndexResults,
} from '../../scripts/lib/faceIndexPlan.mjs'
// C234 — the pure parser/summaries of `pnpm faces:index`: the honest staleness
// accounting and the operator-facing lines.
// C242 — the pure parser/summaries of `pnpm faces:index`: the honest staleness
// accounting (scope B: descriptors, not subjects) and the operator-facing lines.
describe('parseFaceIndexCliArgs', () => {
it('defaults to the dry-run and parses the modes', () => {
@ -47,82 +47,43 @@ describe('parseFaceIndexCliArgs', () => {
})
describe('summarizeFaceIndexResults', () => {
it('counts indexed/linked/failed and aggregates links per subject', () => {
it('counts indexed photos, stored descriptors and failures with the stage', () => {
const summary = summarizeFaceIndexResults([
{ photoId: 1, status: 'indexed', matchedSubjects: [3, 4] },
{ photoId: 2, status: 'indexed', matchedSubjects: [3] },
{ photoId: 3, status: 'indexed', matchedSubjects: [] },
{ photoId: 1, status: 'indexed', descriptorCount: 3 },
{ photoId: 2, status: 'indexed', descriptorCount: 0 },
{ photoId: 3, status: 'indexed', descriptorCount: 1 },
{ photoId: 4, status: 'failed', stage: 'download', error: 'sem arquivo' },
])
expect(summary.indexed).toBe(3)
expect(summary.descriptors).toBe(4)
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', () => {
it('derives the honest gap and the stored descriptor count', () => {
const inventory = summarizeFaceIndexInventory({
queue,
subjects,
currentConsentHash: 'hash-atual',
queue: { indexKey: 'face-api@1.7.15/faceRecognitionNet', totalApproved: 10, stale: 4 },
descriptors: 23,
})
expect(inventory).toEqual({
indexKey: 'k',
indexKey: 'face-api@1.7.15/faceRecognitionNet',
totalApproved: 10,
indexed: 6,
stale: 4,
eligibleSubjects: 2,
activeSubjects: 2,
removedSubjects: 1,
staleConsentSubjects: 1,
linkedPhotos: 2,
linksBySubject: [{ subjectId: 1, photos: 2 }],
descriptors: 23,
})
})
it('treats a missing current consent as stale for everyone', () => {
it('never reports a negative indexed count', () => {
const inventory = summarizeFaceIndexInventory({
queue,
subjects,
currentConsentHash: null,
queue: { indexKey: 'k', totalApproved: 2, stale: 5 },
descriptors: 0,
})
expect(inventory.staleConsentSubjects).toBe(2)
expect(inventory.indexed).toBe(0)
})
})
@ -136,20 +97,14 @@ describe('formatting', () => {
totalApproved: 10,
indexed: 6,
stale: 4,
eligibleSubjects: 2,
activeSubjects: 2,
removedSubjects: 1,
staleConsentSubjects: 1,
linkedPhotos: 2,
linksBySubject: [{ subjectId: 1, photos: 2 }],
descriptors: 23,
},
'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)')
expect(text).toContain('rostos no índice (modelo atual): 23')
})
it('prints the apply summary with failures', () => {
@ -157,20 +112,18 @@ describe('formatting', () => {
mode: 'apply',
target: 'host/db',
model: 'face-api@1.7.15/faceRecognitionNet',
queue: { items: 3, totalApproved: 10, eligibleSubjects: 1 },
queue: { items: 3, totalApproved: 10 },
summary: {
indexed: 2,
failed: 1,
linkedPhotos: 1,
linksBySubject: [{ subjectId: 3, photos: 1 }],
descriptors: 5,
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('processadas: 2 | rostos indexados: 5 | falharam: 1')
expect(text).toContain('✗ foto 4 [analyze]: engine fora')
})
})

View file

@ -6,39 +6,20 @@ import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog'
import {
FACE_SEARCH_DESCRIPTOR_LENGTH,
FACE_SEARCH_MAX_DISTANCE,
FACE_SEARCH_MODEL,
faceEuclideanDistance,
faceStubDescriptorFromBytes,
faceSubjectIsEligible,
findFaceMatch,
findFaceDescriptorPhotoIds,
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.
// C242 — the pure contract of the selfie search: vector validation, the
// euclidean math, the anonymous descriptor match (which by shape cannot carry a
// score or a third-party name) and the result view model.
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)
@ -67,60 +48,41 @@ describe('faceEuclideanDistance', () => {
})
})
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('findFaceDescriptorPhotoIds', () => {
const near = (photoId: number, delta = 0.1) => ({
photoId,
vector: [...vectorOf(0).slice(1), delta],
})
})
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)
const far = (photoId: number, delta = FACE_SEARCH_MAX_DISTANCE + 1) => ({
photoId,
vector: [...vectorOf(0).slice(1), delta],
})
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('returns the distinct photo ids with a face under the threshold', () => {
const ids = findFaceDescriptorPhotoIds(vectorOf(0), [
near(7),
near(7, 0.2),
near(9, 0.3),
far(11),
])
expect(ids).toEqual([7, 9])
})
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()
it('never matches at or beyond the threshold and answers empty when nothing is close', () => {
expect(
findFaceDescriptorPhotoIds(vectorOf(0), [
{ photoId: 1, vector: [...vectorOf(0).slice(1), FACE_SEARCH_MAX_DISTANCE] },
]),
).toEqual([])
expect(findFaceDescriptorPhotoIds(vectorOf(0), [far(2)])).toEqual([])
expect(findFaceDescriptorPhotoIds(vectorOf(0), [])).toEqual([])
})
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()
it('accepts Float32Array descriptors (the in-process cache shape)', () => {
const ids = findFaceDescriptorPhotoIds(vectorOf(0), [
{ photoId: 3, vector: Float32Array.from(near(3).vector) },
])
expect(ids).toEqual([3])
})
})