Principios
Las decisiones de fondo: por que soul se ve asi y no de otra forma.
1. Cero productos pagados#
Solo Workers, D1, R2, Cache API, Turnstile y cron. Nada de KV, Durable Objects, Queues ni Images. No es tacañeria: es lo que hace viable operar muchos productos pequeños a la vez. Un SaaS que no factura todavia tiene que costar practicamente cero.
Esta restriccion tiene consecuencias visibles en el diseño. El rate limiting, por ejemplo, usa D1 en vez de la Cache API —que seria mas rapida— porque la cache es por colo y un atacante que rota de region la evade.
2. Cache-first en lo publico#
Toda pagina publica se sirve desde caches.default con invalidacion explicita al cambiar el
contenido. Asi las visitas normales no tocan D1.
3. D1 sin desperdicio#
D1 es el recurso escaso, asi que cada lectura cuenta:
- Escrituras multiples van en
db.batch([...]), que ademas es atomico. - Datos relacionados salen con un
JOIN, nunca con N+1. requireSessionWithOrg()resuelve sesion, usuario y organizacion en una consulta.- Desde la v4.1 las sesiones son stateless por defecto: el nombre y el
isAdminviajan firmados en el JWT, asi que una request autenticada normal no lee D1.
4. Multi-tenant desde el primer dia#
Toda tabla de negocio lleva org_id REFERENCES orgs(id) y todo query scopea por
organizacion. No es opcional ni se agrega "cuando haga falta": agregarlo despues significa auditar
cada consulta del proyecto.
5. Escapar siempre#
Todo contenido de usuario que se interpola en HTML pasa por escapeHtml(). Las funciones de
@jun/soul/ui usan html\`\`, que escapa automaticamente lo interpolado, como JSX.
6. Sin build de frontend#
HTML renderizado en el servidor y JavaScript inline minimo. La interactividad sin recargar se
resuelve con el patron AJAX propio de soul (data-ajax-target), no con un framework.
La libreria misma tampoco tiene build: se publican los .ts tal cual. Menos piezas que se
puedan romper entre tu codigo y lo que corre en produccion.
7. Admision pull-based#
La contracara es igual de importante: lo que no se gana su lugar, sale. El CMS se elimino en la v4.0.0 tras cuatro versiones de crecimiento, porque era un tercio del codigo y el modulo menos adoptado.
Por que importa: un caso real#
El portal de clientes de Ply tenia su propio login por codigo al email, escrito a mano. Al
migrarlo a @jun/soul/credentials el recuento de lineas quedo casi igual —de 273 a 280—,
asi que el argumento no era "menos codigo". Era correccion: la version propia tenia tres
fallas reales.
El codigo se generaba con Math.random()
// ANTES — predecible: quien observe algunos codigos puede anticipar los siguientes
const code = Math.floor(100000 + Math.random() * 900000).toString();
// DESPUES — crypto.getRandomValues, con rechazo de los bytes altos para no
// sesgar hacia los primeros digitos (256 no es multiplo de 10)
const { secret } = await issueCredential(db, {
kind: "otp", subject: email, format: "digits", digits: 6,
});
El consumo de un solo uso no era atomico
// ANTES — se validaba y DESPUES se borraba: dos requests con el mismo codigo
// valido podian pasar ambas
if (hash === row.code_hash) {
await db.prepare("DELETE FROM client_otp WHERE id = ?").bind(row.id).run();
// ...crear sesion
}
// DESPUES — el WHERE exige que siga sin consumir; se decide por meta.changes,
// asi que de dos requests simultaneas gana exactamente una
const res = await db
.prepare("UPDATE soul_credentials SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL")
.bind(now, row.id)
.run();
if (!res.meta.changes) return { ok: false, reason: "consumed" };
El rate limit era evadible y filtraba informacion
Contaba filas de OTP, que solo se insertaban si el email tenia arriendos activos. Un correo sin arriendos nunca topaba el limite, asi que el 429 delataba quien era cliente. Ahora se cuenta toda request, antes de mirar el negocio.
El mismo patron, otra vez#
En licet, /activate contaba los dispositivos activos y despues insertaba. Dos activaciones
simultaneas leian el mismo total y ambas pasaban: una licencia de 2 dispositivos terminaba con 5
activos —justo el limite que vende el producto—. La correccion es la misma idea:
-- El cupo se comprueba DENTRO de la statement que escribe, no antes
INSERT INTO activations (org_id, license_id, fingerprint, token_hash)
SELECT ?, ?, ?, ?
WHERE (SELECT COUNT(*) FROM activations
WHERE license_id = ? AND status = 'active') < ?;
-- y se decide por meta.changes
Como se ve el ahorro de D1#
// 3 consultas: sesion, usuario, organizacion
app.use("*", auth.requireSession());
app.use("*", tenancy.requireOrg());
// 1 consulta: las tres cosas en un JOIN
app.use("*", tenancy.requireSessionWithOrg());
Y con statelessUser activo (el default desde la v4.1), una request autenticada que no
necesita la organizacion no toca D1 en absoluto.