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

224 lines
7.8 KiB
JavaScript

/**
* 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))
})