soul v4.2.0

@jun/soul/credentials

Credentials

Una primitiva para OTP por email, magic links y API keys.

La idea#

Un codigo al email, un magic link y una API key parecen tres cosas distintas, pero son la misma: un secreto con expiracion que se valida por hash. Cambia el formato, la vida util y si se consume al usarse. soul los unifica en una tabla y una API; cada proyecto arma su flujo encima.

De que se hace cargo soul#

  • El secreto se guarda hasheado, nunca en claro. En la base solo vive su sha256.
  • La comparacion es en tiempo constante.
  • El uso unico se consume de forma atomica: no hay ventana de carrera.
  • Los intentos fallidos se cuentan y matan la credencial (fuerza bruta sobre codigos cortos).
  • La generacion usa crypto.getRandomValues, con rechazo de los bytes altos para no sesgar los digitos.

Emitir#

TypeScript
import { issueCredential } from "@jun/soul/credentials";

const { secret, hint, expiresAt } = await issueCredential(db, {
  kind: "otp",              // separa espacios: un secreto de un kind nunca vale en otro
  subject: email,           // a quien pertenece, en tus terminos
  format: "digits",         // "digits" (tecleable) o "token" (hex opaco)
  digits: 6,
  ttlSeconds: 600,
  singleUse: true,
  maxAttempts: 5,
  replacePrevious: true,    // matar el codigo anterior de este mismo email
});

Validar: dos modos#

ModoCuandoComportamiento
Con subjectOTP: el secreto es corto y adivinableBusca la credencial viva de ese titular y cuenta los intentos fallidos. Al llegar a maxAttempts deja de servir.
Sin subjectMagic link, API key, token de sesionEl hash del secreto es la identidad. No hay intentos que contar porque un token de 32 bytes no se adivina: limita por IP con ratelimit.
TypeScript
import { verifyCredential } from "@jun/soul/credentials";

const r = await verifyCredential(db, { kind: "otp", subject: email, secret: codigo });

if (!r.ok) {
  // r.reason: "not_found" | "expired" | "consumed" | "revoked" | "too_many_attempts"
  return errorPara(r.reason);
}
r.credential;   // { id, kind, subject, orgId, hint, expiresAt, meta, createdAt }

El resto de la API#

revokeCredentials(db, { id?, kind?, subject?, orgId? })
Revoca y devuelve cuantas filas afecto. Lanza si no le pasas ningun filtro: revocar la tabla entera siempre es un bug.
listCredentials(db, { kind, subject?, orgId?, includeInactive? })
Lista las vivas. Nunca hay secretos aca — sirve para un panel de API keys.
cleanupCredentials(db, olderThanSeconds = 7d)
Borra las muertas (expiradas, consumidas o revocadas). Llamalo desde el cron; las vivas no se tocan.
requireCredential({ kind, header?, onUnauthorized? })
Guard para endpoints con API key. Lee Authorization: Bearer y deja la credencial en c.var.credential.

Migracion#

Necesita 0007_soul_credentials.sql (npx soul sync-migrations), que crea la tabla soul_credentials: una sola para los tres flujos.