teqo-backup/scripts/lib/faceEnrollPlan.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

91 lines
3.8 KiB
JavaScript

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