Que es soul
La infraestructura comun de los micro-SaaS de JUN, sobre Cloudflare Workers.
El problema que resuelve#
El costo de un micro-SaaS no esta en construirlo: esta en mantener varios a la vez.
Sin una base comun, cada bug de seguridad, cada cambio de OAuth y cada mejora de permisos hay que
hacerla N veces. @jun/soul resuelve una vez lo que todo SaaS necesita —autenticacion,
organizaciones, roles, planes, panel de administracion— y lo presta a cada proyecto.
Un proyecto nuevo nace con todo eso funcionando desde el dia uno, y solo escribe lo que lo hace distinto: su logica de negocio y su identidad visual.
Los modulos#
@jun/soul/authLogin con Google, sesion JWT y guards.
Tenancy@jun/soul/tenancyOrganizaciones, roles, permisos y aislamiento.
Plans@jun/soul/plansPlanes y limites tipados.
Admin@jun/soul/adminPanel de superadmin listo para montar.
Credentials@jun/soul/credentialsOTP, magic links y API keys.
Webhooks@jun/soul/webhooksVerificacion Standard Webhooks.
Rate limit@jun/soul/ratelimitVentana fija sobre D1.
UI@jun/soul/uiShell, theme y design system.
Lib@jun/soul/libUtilidades: hash, cache, email, slugs.
Static@jun/soul/staticPaginas a 0 requests de Worker.
Testing@jun/soul/testingHelpers y chequeos de seguridad.
Como se ve en la practica#
Un proyecto completo con login, organizaciones y panel de admin cabe en unas pocas lineas, porque lo unico que se escribe es el cableado:
import { Hono } from "hono";
import type { SoulEnv } from "@jun/soul";
import { securityHeaders } from "@jun/soul/ui";
import { auth, tenancy, admin, shell } from "./core";
import facturas from "./modules/facturas";
const app = new Hono<{ Bindings: SoulEnv }>();
app.use("*", securityHeaders({}));
app.route("/auth", auth.routes); // login, callback, logout
app.route("/facturas", facturas); // tu negocio, ya scopeado por org
app.route("/admin", admin.routes); // panel de superadmin
export default app;
Las reglas que no se negocian#
soul existe para que operar muchos productos sea barato, y eso impone restricciones duras. Un cambio que rompa cualquiera de estas se rechaza:
- Cero productos pagados de Cloudflare. Solo Workers, D1, R2, Cache API, Turnstile y cron. Prohibidos KV, Durable Objects, Queues e Images.
- Una sola dependencia:
hono, y como peer. - Sin build propio: se publican los
.tsdirectamente. - Sin frameworks de cliente ni bundlers. HTML server-side y JS inline minimo.
- Multi-tenant siempre: toda tabla de negocio lleva
org_idy todo query scopea por org. - Cache-first en paginas publicas, con invalidacion explicita.
Que entra al nucleo#
Por eso webhooks y ratelimit salieron de licet, y credentials nacio de ver
el mismo codigo dos veces (el portal OTP de Ply y las activaciones de licet). Y por eso en la v4.0.0
se elimino el CMS: era un tercio de la libreria y su pieza mas compleja, siendo la menos
usada. Un CMS con bloques, categorias y SEO es un producto, no infraestructura.
Proyectos que la usan#
soul no es teoria: hay cinco productos encima, todos alineados en la misma version.
| Proyecto | Que es | Estado |
|---|---|---|
| Memorial | Memoriales digitales (personas y mascotas) | En produccion |
| NutriLabel | Etiquetas nutricionales para Chile | En produccion |
| licet | Licenciamiento y activaciones de software | En desarrollo |
| Ply | Muebles como servicio (FaaS) | En desarrollo |
| Portfolio | Mini-ERP para gestionar el portafolio | Probado local |
Un modulo de negocio completo#
Esto es lo que genera soul g module facturas: un CRUD entero donde el aislamiento por
organizacion y los permisos ya vienen resueltos. Fijate que no hay una sola comprobacion manual
de "¿este registro es de mi org?".
import { Hono } from "hono";
import type { SoulEnv } from "@jun/soul";
import type { TenancyVariables } from "@jun/soul/tenancy";
import { escapeHtml } from "@jun/soul/lib";
import { shell, errorPage, tenancy, ui } from "../core";
const facturas = new Hono<{ Bindings: SoulEnv; Variables: TenancyVariables }>();
// Sesion + usuario + organizacion activa en UN solo JOIN.
facturas.use("*", tenancy.requireSessionWithOrg());
facturas.get("/", tenancy.requirePermission("facturas.read"), async (c) => {
const { results } = await c.env.DB.prepare(
"SELECT id, title, created_at FROM facturas WHERE org_id = ? ORDER BY created_at DESC",
)
.bind(c.var.org.id) // el scoping es explicito y obligatorio
.all();
return c.html(shell({ title: "Facturas", body: render(results) }));
});
facturas.get("/:id", tenancy.requirePermission("facturas.read"), async (c) => {
// Si la factura es de otra organizacion responde 404, igual que si no
// existiera: no se filtra que el recurso existe.
const row = await tenancy.requireTenantResource(c, "facturas", c.req.param("id"));
if (row instanceof Response) return row;
return c.html(shell({ title: row.title, body: detalle(row) }));
});
export default facturas;
Los tres niveles de pagina#
Cada pagina elige cuanto cuesta servirla. Es la decision de arquitectura mas rentable en Cloudflare, porque los Workers son abundantes pero D1 es escaso:
| Nivel | Como se sirve | Costo por visita |
|---|---|---|
| Estatica | Asset prerenderizado (soul build) | 0 requests de Worker, 0 D1 |
| Publica dinamica | Worker + cache-first | 1 request, 0 D1 en hit |
| Portal | Worker tras el login | 1 request, N consultas D1 |
Ver Tres niveles de pagina para elegir bien.