Quickstart
La Parte 1 usa create-echotechs-app (spec 3.5) — el camino real para un proyecto nuevo. La
Parte 2 es reference-app, el módulo de este mismo repo que prueba que los ocho módulos backend
ensamblan — útil si querés ver el core sin pasar por el frontend, o como el backend contra el que probar un
proyecto scaffoldeado (que es justo lo que hace la Parte 1, sección "Probarlo contra reference-app", más
abajo). Cada comando de esta página se corrió de verdad contra infraestructura real (Postgres, OpenFGA,
MinIO, SMTP), no son ejemplos ilustrativos.
Parte 1 — create-echotechs-app
El criterio de éxito de la sección 6 del spec: "un desarrollador debería poder arrancar un proyecto nuevo,
agregar las dependencias de platform-core y @echotechs/*, y en el primer día ya tener: login
funcionando, un recurso de ejemplo con multi-tenancy automático, subida de archivos, y una tabla en el
frontend — sin escribir una sola línea de código de infraestructura."
Los cinco paquetes (los cuatro @echotechs/* más @echotechs/create-echotechs-app) ya están publicados en
Nexus (npm-hosted) — verificado instalándolos de verdad desde un proyecto externo. Como el acceso anónimo
está apagado en toda la instancia (igual que Maven), npx necesita saber dónde vive el scope @echotechs.
El scaffolder también se publica bajo ese scope, así que con
@echotechs:registry=https://nexus.lab.echotechs.net/repository/npm-hosted/ (con sus credenciales) en tu
.npmrc de usuario, npx @echotechs/create-echotechs-app resuelve sin más — antes no era así, porque el
paquete se publicaba sin scope y el mapeo @echotechs:registry=... no lo cubría; por eso hacía falta el
--registry a mano. Ahora sí:
npx @echotechs/create-echotechs-app mi-proyecto
cd mi-proyecto
cp .npmrc.example .npmrc # credenciales de Nexus — ver el comentario en ese archivo
cp .env.local.example .env.local # NEXT_PUBLIC_API_BASE_URL, apuntado a tu backend platform-core
pnpm install
pnpm dev
Para un one-off en una máquina sin el @echotechs:registry configurado, todavía podés pasar --registry
a mano:
npx --registry=https://nexus.lab.echotechs.net/repository/npm-hosted/ @echotechs/create-echotechs-app mi-proyecto
Genera un proyecto Next.js App Router con los cuatro paquetes @echotechs/* ya integrados — ver
Paquetes frontend para el detalle de cada uno. De la lista del criterio de éxito:
- Login funcionando —
/loginusa@echotechs/auth-webcontracore-authreal. - Un recurso de ejemplo con multi-tenancy —
/requests, parametrizado porNEXT_PUBLIC_RESOURCE_PATH/NEXT_PUBLIC_RESOURCE_TYPE(apuntalo a tu propia entidad de dominio). - Subida de archivos — el mismo
/requeststrae@echotechs/upload-widgetpor fila. - Una tabla en el frontend —
@echotechs/ui-kit's<Table>, tanto en/requestscomo en/admin/config(ConfigAdminPanel, que funciona contra cualquier backend platform-core sin configuración adicional).
docker-compose.yml en el proyecto generado trae los mismos cuatro servicios que la Parte 2 de esta
página levanta a mano (Postgres, OpenFGA, MinIO, Mailpit).
Probarlo contra reference-app
Así es como se verificó el scaffold de punta a punta durante el desarrollo de estos paquetes — útil si querés repetirlo vos mismo:
- Segui la Parte 2 de esta página hasta el Paso 4 (login real) — necesitás
reference-appcorriendo con infraestructura real detrás, incluyendo el paso 5 (dar de alta al usuario demo comoadminen OpenFGA). reference-app'sapplication-dev.ymlya traeechotechs.web.cors.allowed-origins: http://localhost:3000— el origen que usapnpm dev/pnpm startde un proyectocreate-echotechs-apppor defecto.- En
.env.localdel proyecto scaffoldeado:NEXT_PUBLIC_API_BASE_URL=http://localhost:18080(o el puerto que le hayas dado areference-app),NEXT_PUBLIC_RESOURCE_PATH=/api/purchase-requests,NEXT_PUBLIC_RESOURCE_TYPE=purchase_request— apuntando el recurso de ejemplo genérico aPurchaseRequest, la única entidad de dominio real quereference-appexpone. pnpm build && pnpm start, y listo: login conadmin/changeme, crear una solicitud, subirle un archivo (contra MinIO real víacore-storage), y editarcore-configdesde/admin/config.
Parte 2 — Proyecto guiado: reference-app de punta a punta
Esta parte no es un ejemplo aparte — es correr literalmente reference-app, el módulo de este mismo repo
que existe para probar que los ocho módulos del core ensamblan en un servicio real. Al final vas a tener:
login con cookies, un recurso (PurchaseRequest) con multi-tenancy real vía OpenFGA, una máquina de
estados con auditoría, un email real enviado al aprobar, un archivo adjunto real en MinIO, un PDF generado
y descargado, y una personalización sin redeploy vía core-config. Cada paso de acá abajo se ejecutó tal
cual contra los cuatro servicios reales — no son capturas de pantalla ni pseudocódigo.
Paso 0 — Clonar y compilar
git clone https://github.com/echotech-sv/echotechs-core.git
cd echotechs-core
# Gradle 8.14 no arranca bajo JDK 25 — necesitás un JDK 21 como launcher y el 25 sólo como toolchain.
./gradlew :reference-app:bootJar --no-daemon \
-Porg.gradle.java.installations.paths=/ruta/a/tu/jdk25 \
-Porg.gradle.java.installations.auto-detect=false
Esto deja el jar en reference-app/build/libs/reference-app-0.1.0-SNAPSHOT.jar.
Paso 1 — Levantar la infraestructura real
Cuatro contenedores, cada uno independiente:
docker run -d --name pg-quickstart \
-e POSTGRES_DB=referenceapp -e POSTGRES_USER=referenceapp_user -e POSTGRES_PASSWORD=app \
-p 15432:5432 postgres:16
docker run -d --name openfga-quickstart -p 18081:8080 openfga/openfga:v1.18.1 run
docker run -d --name minio-quickstart -p 19000:9000 -p 19001:9001 \
-e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data --console-address ":9001"
docker run -d --name mailpit-quickstart -p 11025:1025 -p 18025:8025 axllent/mailpit
mailpit es un servidor SMTP de prueba con una UI/API para leer lo que llega — no aparece en ningún
build.gradle.kts del core, es sólo la forma más simple de ver un email real localmente sin una cuenta de
verdad.
reference-app crea el esquema app (datos de dominio) además de core (tablas de framework). Postgres
resuelve search_path como "$user", public — si tu usuario de conexión se llama, por ejemplo, app
igual que ese esquema, Postgres empieza a preferir el esquema app para nombres de tabla sin calificar a
mitad de la misma corrida de Liquibase, justo cuando se ejecuta el changeset que crea ese esquema. El
resultado: la tabla de bookkeeping propia de Liquibase (databasechangelog) termina partida entre public
y app, y el próximo arranque intenta re-crear tablas que ya existen (relation "xxx" already exists).
Pasó de verdad armando esta guía. Usá un usuario que no coincida con ningún nombre de esquema —
referenceapp_user acá, no app.
Creá el bucket de MinIO (el cliente S3 no lo hace por vos):
docker exec minio-quickstart mkdir -p /data/reference-app
Paso 2 — Crear el store de OpenFGA
curl -s -X POST http://localhost:18081/stores -H "Content-Type: application/json" \
-d '{"name":"reference-app-quickstart"}'
{"id":"01KYNPJRSM37Y6MCAH11S989TC","name":"reference-app-quickstart", "..."}
Guardá ese id — es $STORE_ID en los pasos siguientes.
Paso 3 — Publicar el modelo de autorización (una sola vez)
FgaAuthorizationModelPublisher no corre solo al arrancar (cada llamada crea una versión nueva del
modelo) — reference-app lo cablea a un perfil, fga-bootstrap, activado sólo para esta corrida:
@Component
@Profile("fga-bootstrap")
public class FgaBootstrapRunner implements ApplicationRunner {
private static final String PURCHASE_REQUEST_TYPE_EXTENSION = """
type purchase_request
relations
define owner_organization: [organization]
define can_view: member from owner_organization
define can_edit: admin from owner_organization
""";
@Override
public void run(ApplicationArguments args) {
String dsl = publisher.readBaseModelDsl() + PURCHASE_REQUEST_TYPE_EXTENSION;
String modelId = publisher.publish(dsl);
log.info("Published OpenFGA authorization model {} — ...", modelId);
}
}
Arrancá con ese perfil (dejalo corriendo — es un servidor web, no un comando que termina):
reference-app usa el motor OpenFGAreference-app todavía inyecta FgaTupleWriter (una dependencia dura del motor OpenFGA), por eso este
quickstart lo arranca con echotechs.authz.engine=openfga. La librería (core-authz) tiene Cedar como
motor default — un proyecto nuevo arranca con Cedar sin setear nada y sin levantar OpenFGA. Ver
core-authz.
java -jar reference-app/build/libs/reference-app-0.1.0-SNAPSHOT.jar \
--spring.profiles.active=dev,postgres,fga-bootstrap \
--server.port=18080 \
--spring.datasource.url=jdbc:postgresql://localhost:15432/referenceapp \
--spring.datasource.username=referenceapp_user \
--spring.datasource.password=app \
--echotechs.auth.jwt.secret="$(openssl rand -base64 32)" \
--echotechs.authz.engine=openfga \
--echotechs.authz.openfga.api-url=http://localhost:18081 \
--echotechs.authz.openfga.store-id=$STORE_ID \
--echotechs.storage.s3.endpoint=http://localhost:19000 \
--echotechs.storage.s3.bucket=reference-app \
--echotechs.storage.s3.access-key-id=minioadmin \
--echotechs.storage.s3.secret-access-key=minioadmin \
--echotechs.storage.s3.path-style-access=true \
--spring.mail.host=localhost \
--spring.mail.port=11025 \
--echotechs.notifications.email.from=no-reply@echotechs.dev
En el log vas a ver, entre el arranque normal de Spring Boot y Liquibase creando core/app:
INFO ... FgaBootstrapRunner : Published OpenFGA authorization model 01KYNPRBTEEC7SPCEAFESX2ETH — set echotechs.authz.openfga.authorization-model-id to this value.
No hace falta setear ese id — sin authorization-model-id, OpenFGA usa el último modelo publicado del
store, que es exactamente este. Matá el proceso (Ctrl+C) y arrancalo de nuevo sin fga-bootstrap en
--spring.profiles.active — es un paso deliberado, no algo que corra en cada boot.
Paso 4 — Login real
Con el servidor arriba (sin el perfil fga-bootstrap esta vez):
curl -i -X POST http://localhost:18080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"changeme"}'
HTTP/1.1 200
Set-Cookie: access_token=eyJhbGciOiJIUzI1NiJ9...; HttpOnly; SameSite=Lax
Set-Cookie: refresh_token=...; Path=/api/auth; HttpOnly; SameSite=Lax
{"userId":"00000000-0000-0000-0000-000000000002","organizationId":"00000000-0000-0000-0000-000000000001","sessionId":"..."}
admin/changeme es DemoUserCredentialAuthenticator — un mapa hardcodeado de un solo usuario, para
verificar el flujo sin una tabla de usuarios real. Los tokens no están en el body, viajan sólo en
cookies HttpOnly. Guardá el valor de access_token — es $COOKIE en todo lo que sigue.
Paso 5 — Dar de alta al usuario demo como admin de su organización en OpenFGA
Loguearse no otorga tuplas por sí solo — es exactamente el mismo paso que harías al dar de alta un usuario real:
curl -s -X POST http://localhost:18081/stores/$STORE_ID/write \
-H "Content-Type: application/json" \
-d '{
"writes": {"tuple_keys": [
{"user": "user:00000000-0000-0000-0000-000000000002",
"relation": "admin",
"object": "organization:00000000-0000-0000-0000-000000000001"}
]}
}'
Paso 6 — Crear, enviar y aprobar un recurso: multi-tenancy + workflow + notificación en una sola pieza
curl -s -X POST http://localhost:18080/api/purchase-requests \
-b "access_token=$COOKIE" -H 'Content-Type: application/json' \
-d '{"title":"Laptops para los nuevos ingresos"}'
{"organizationId":"...0001","createdBy":"...0002","title":"Laptops para los nuevos ingresos","id":"2b2c5540-...","status":"DRAFT"}
Esta llamada, en PurchaseRequestService.create() (real, el archivo completo está en
reference-app/src/main/java/.../purchaserequest/PurchaseRequestService.java), hace la mitad del trabajo
de multi-tenancy que normalmente se olvida escribir a mano:
@Transactional
public PurchaseRequest create(String title) {
UUID orgId = TenantContext.getCurrentOrg();
PurchaseRequest saved = repository.save(new PurchaseRequest(orgId, TenantContext.getCurrentPrincipal().principalId(), title));
// Sin esta tupla, ni siquiera quien lo creó puede leerlo después.
tupleWriter.grant(FgaId.of("organization", orgId), "owner_organization", FgaId.of("purchase_request", saved.getId()));
return saved;
}
Guardá el id de la respuesta ($ID) y avanzá el estado:
curl -s -X POST http://localhost:18080/api/purchase-requests/$ID/submit -b "access_token=$COOKIE"
# -> "status": "SUBMITTED"
curl -s -X POST http://localhost:18080/api/purchase-requests/$ID/approve -b "access_token=$COOKIE"
# -> "status": "APPROVED"
Cada transición pasa por WorkflowTransitionService.transition(...), que valida contra la máquina de
estados registrada (DRAFT → submit → SUBMITTED → approve → APPROVED) y graba una fila de auditoría —
intentar approve directo desde DRAFT responde 409 INVALID_TRANSITION, no un estado inconsistente.
Al aprobar, PurchaseRequestService.approve() manda un email real:
curl -s http://localhost:18025/api/v1/messages
{"messages":[{"From":{"Address":"no-reply@echotechs.dev"},"To":[{"Address":"purchasing@example.com"}],
"Subject":"Your purchase request was approved",
"Snippet":"Your purchase request \"Laptops para los nuevos ingresos\" has been approved."}]}
Eso llegó por SMTP de verdad a Mailpit, no un mock — NotificationService.send(...) con el sender de
email real de core-notifications.
Paso 7 — Adjuntar un archivo (contra MinIO real)
Los adjuntos no tienen endpoint propio en PurchaseRequestController — usan directamente los endpoints
genéricos de core-storage, pasando purchase_request como resourceType:
curl -s -X POST http://localhost:18080/api/storage/files -b "access_token=$COOKIE" \
-H 'Content-Type: application/json' \
-d "{\"resourceType\":\"purchase_request\",\"resourceId\":\"$ID\",\"fileName\":\"cotizacion.pdf\",\"contentType\":\"application/pdf\"}"
{"fileId":"30208c4d-...","uploadUrl":"http://localhost:19000/reference-app/.../cotizacion.pdf?X-Amz-...","expiresAt":"..."}
El frontend subiría los bytes directo a esa URL prefirmada — el backend nunca los toca:
echo "contenido de prueba" > /tmp/cotizacion.pdf
curl -X PUT "$UPLOAD_URL" -H "Content-Type: application/pdf" --data-binary @/tmp/cotizacion.pdf
curl -s -X POST "http://localhost:18080/api/storage/files/$FILE_ID/confirm" -b "access_token=$COOKIE"
# -> {"status":"CONFIRMED","sizeBytes":37,...}
Paso 8 — Generar y descargar un reporte PDF real
curl -s -X POST "http://localhost:18080/api/purchase-requests/$ID/report" -b "access_token=$COOKIE"
{"id":"fbffae3f-...","originalFileName":"purchase-request-2b2c5540-...-summary.pdf","status":"CONFIRMED","sizeBytes":901}
ReportService.generateAndStore(...) (core-reporting) renderiza el PDF con OpenPDF y lo sube vía
core-storage en la misma llamada. Descargalo y confirmá que es un PDF de verdad:
DOWNLOAD_URL=$(curl -s "http://localhost:18080/api/storage/files/$REPORT_FILE_ID/download-url" -b "access_token=$COOKIE" | jq -r .downloadUrl)
curl -s -o reporte.pdf "$DOWNLOAD_URL"
file reporte.pdf
# -> reporte.pdf: PDF document, version 2.0, 1 pages
Paso 9 — Personalizar sin redeploy (core-config)
El asunto del email de aprobación se resuelve en runtime desde core-config, por organización:
curl -s -X PUT "http://localhost:18080/api/organizations/00000000-0000-0000-0000-000000000001/config/purchase_request.approval_email_subject" \
-b "access_token=$COOKIE" -H 'Content-Type: application/json' \
-d '{"value":"✅ Tu compra fue aprobada"}'
Creá y aprobá otro PurchaseRequest (repetí el paso 6) y el email siguiente ya sale con el asunto nuevo —
sin reiniciar el proceso, sin redeploy. Ese es exactamente el punto de core-config: un valor que un
cliente quiere ajustar en runtime, no algo que cambie junto con el código.
Paso 10 — Aislamiento entre organizaciones
Esta es la prueba que de verdad importa, y reference-app sólo trae un usuario demo (admin), así que
reproducirla a mano por curl necesitaría una segunda identidad autenticada que el demo mínimo no da de
alta. Está verificada de punta a punta en
PurchaseRequestFlowIntegrationTest.aUserFromAnotherOrganizationCannotSeeSomeoneElsesPurchaseRequest
(corre contra OpenFGA/MinIO/SMTP reales vía Testcontainers, no mocks):
@Test
void aUserFromAnotherOrganizationCannotSeeSomeoneElsesPurchaseRequest() throws Exception {
// ... crear un purchase request como el admin demo ...
UserPrincipal stranger = new UserPrincipal(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
assertThatThrownBy(() -> withPrincipal(stranger, () -> purchaseRequestService.get(id)))
.hasCauseInstanceOf(AccessDeniedException.class);
}
En tu propio proyecto, con un segundo usuario real de otra organización, es literalmente repetir el paso 4
(login) con esas credenciales y el paso 6 (GET /api/purchase-requests/{id}) — la respuesta es
403 ACCESS_DENIED, producida por RequiresPermissionAspect → AccessDeniedException →
GlobalExceptionHandler, sin una sola línea de if comparando organizaciones en el controller.
Beneficios: qué no tuviste que escribir
| Si lo hicieras desde cero | Con platform-core |
|---|---|
Login, hash de contraseña, JWT, rotación de refresh token, cookies HttpOnly | core-auth — dos interfaces a implementar (UserCredentialAuthenticator, PasswordResetHandler) |
Un if (recurso.getOrgId().equals(...)) en cada endpoint, y confiar en que nadie se olvide de escribirlo | core-authz — una anotación, el aspecto arma el check() |
Cliente S3, URLs prefirmadas, metadata de archivos, path-style para MinIO | core-storage — initiateUpload/confirmUpload/presignDownload |
| Una máquina de estados a mano, tabla de auditoría, eventos de dominio | core-workflow — WorkflowDefinition.builder(...) declarativo |
| Cliente SMTP, motor de plantillas, manejo de adjuntos de email | core-notifications — NotificationService.send(...) |
| Librería de PDF, layout, subida del resultado | core-reporting — ReportTemplate + generateAndStore(...) |
| Una tabla de configuración con cache, o convencer a alguien de pagar un CMS | core-config — ConfigService.getValue(...), cache Caffeine incluido |
Lo que todavía hay que escribir a mano: publicar el modelo de OpenFGA (paso 3), escribir las tuplas al
crear/asignar cada recurso (paso 6), y las dos interfaces de core-auth. Ninguno de los tres necesita
frontend — son exactamente lo que create-echotechs-app debería automatizar cuando exista.
Aplicar esto a tu propio proyecto
Los pasos 0 a 5 de arriba (infra, store, modelo, login) son iguales para cualquier proyecto nuevo — sólo
cambia el nombre del store y las credenciales. Para tu propio recurso, el patrón completo a copiar es
literalmente reference-app/src/main/java/dev/echotechs/referenceapp/purchaserequest/:
| Archivo real | Qué define |
|---|---|
PurchaseRequest.java | La entidad, extendiendo CoreEntity |
PurchaseRequestRepository.java | El repositorio, sin nada especial |
PurchaseRequestWorkflowConfig.java | Los estados y transiciones válidas |
PurchaseRequestSummaryReportTemplate.java | El layout del PDF |
PurchaseRequestService.java | Dónde se escriben las tuplas y se orquesta todo lo demás |
web/PurchaseRequestController.java | Los endpoints REST |
db/changelog/app/001-purchase-request.xml | El changeset de la tabla propia |
Y en el build.gradle.kts de tu proyecto:
plugins {
java
id("org.springframework.boot") version "4.1.0"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.tuproyecto"
version = "0.1.0-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
maven {
url = uri("https://nexus.lab.echotechs.net/repository/maven-public")
credentials {
username = System.getenv("NEXUS_USERNAME")
password = System.getenv("NEXUS_PASSWORD")
}
}
}
dependencies {
// core-authz arrastra core-auth, que arrastra core-web y core-persistence.
implementation("dev.echotechs.core:core-authz:0.1.0")
implementation("dev.echotechs.core:core-storage:0.1.0")
implementation("dev.echotechs.core:core-workflow:0.1.0")
implementation("dev.echotechs.core:core-notifications:0.1.0")
implementation("dev.echotechs.core:core-reporting:0.1.0")
implementation("dev.echotechs.core:core-config:0.1.0")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test> {
useJUnitPlatform()
}
Y el changelog master de tu proyecto, incluyendo core-persistence una sola vez, primero (ver la nota en
core-persistence):
<databaseChangeLog ...>
<include file="db/changelog/core-persistence/changelog-master.xml"/>
<include file="db/changelog/core-auth/changelog-master.xml"/>
<include file="db/changelog/core-storage/changelog-master.xml"/>
<include file="db/changelog/core-workflow/changelog-master.xml"/>
<include file="db/changelog/core-notifications/changelog-master.xml"/>
<include file="db/changelog/core-config/changelog-master.xml"/>
<!-- tus propios changesets debajo -->
<include file="001-tu-recurso.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
Qué falta para cumplir el criterio de éxito del spec
De los cuatro puntos de la sección 6 del spec, el backend cubre tres — y ahora los tres están verificados juntos en un solo flujo real, no sólo por módulo separado:
| Objetivo | Estado |
|---|---|
| Login funcionando | ✅ core-auth, verificado arriba y en AuthFlowIntegrationTest |
| Recurso con multi-tenancy automático | ✅ core-authz, verificado arriba contra OpenFGA real |
| Subida de archivos | ✅ core-storage, verificado arriba contra MinIO real |
| Workflow con auditoría | ✅ core-workflow, verificado arriba |
| Notificaciones | ✅ core-notifications, email real verificado arriba |
| Reportes | ✅ core-reporting, PDF real verificado arriba |
| Configuración en runtime | ✅ core-config, verificado arriba sin redeploy |
| Una tabla en el frontend | ❌ @echotechs/ui-kit no existe |
Y el "sin escribir una sola línea de código de infraestructura" todavía no se cumple del todo: hoy hay que
implementar UserCredentialAuthenticator/PasswordResetHandler, publicar el modelo de OpenFGA a mano, y
escribir las tuplas de cada recurso — los tres pasos que create-echotechs-app debería automatizar.