Saltar al contenido principal
Versión: main (sin publicar)

Quickstart

Dos caminos, ambos verificados

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:

  1. Login funcionando — /login usa @echotechs/auth-web contra core-auth real.
  2. Un recurso de ejemplo con multi-tenancy — /requests, parametrizado por NEXT_PUBLIC_RESOURCE_PATH/NEXT_PUBLIC_RESOURCE_TYPE (apuntalo a tu propia entidad de dominio).
  3. Subida de archivos — el mismo /requests trae @echotechs/upload-widget por fila.
  4. Una tabla en el frontend — @echotechs/ui-kit's <Table>, tanto en /requests como 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:

  1. Segui la Parte 2 de esta página hasta el Paso 4 (login real) — necesitás reference-app corriendo con infraestructura real detrás, incluyendo el paso 5 (dar de alta al usuario demo como admin en OpenFGA).
  2. reference-app's application-dev.yml ya trae echotechs.web.cors.allowed-origins: http://localhost:3000 — el origen que usa pnpm dev/pnpm start de un proyecto create-echotechs-app por defecto.
  3. En .env.local del proyecto scaffoldeado: NEXT_PUBLIC_API_BASE_URL=http://localhost:18080 (o el puerto que le hayas dado a reference-app), NEXT_PUBLIC_RESOURCE_PATH=/api/purchase-requests, NEXT_PUBLIC_RESOURCE_TYPE=purchase_request — apuntando el recurso de ejemplo genérico a PurchaseRequest, la única entidad de dominio real que reference-app expone.
  4. pnpm build && pnpm start, y listo: login con admin/changeme, crear una solicitud, subirle un archivo (contra MinIO real vía core-storage), y editar core-config desde /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.

El usuario de Postgres NO puede llamarse igual que un esquema

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:

reference-app/src/main/java/dev/echotechs/referenceapp/FgaBootstrapRunner.java (real, no ilustrativo)
@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 OpenFGA

reference-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 ceroCon platform-core
Login, hash de contraseña, JWT, rotación de refresh token, cookies HttpOnlycore-auth — dos interfaces a implementar (UserCredentialAuthenticator, PasswordResetHandler)
Un if (recurso.getOrgId().equals(...)) en cada endpoint, y confiar en que nadie se olvide de escribirlocore-authz — una anotación, el aspecto arma el check()
Cliente S3, URLs prefirmadas, metadata de archivos, path-style para MinIOcore-storage — initiateUpload/confirmUpload/presignDownload
Una máquina de estados a mano, tabla de auditoría, eventos de dominiocore-workflow — WorkflowDefinition.builder(...) declarativo
Cliente SMTP, motor de plantillas, manejo de adjuntos de emailcore-notifications — NotificationService.send(...)
Librería de PDF, layout, subida del resultadocore-reporting — ReportTemplate + generateAndStore(...)
Una tabla de configuración con cache, o convencer a alguien de pagar un CMScore-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 realQué define
PurchaseRequest.javaLa entidad, extendiendo CoreEntity
PurchaseRequestRepository.javaEl repositorio, sin nada especial
PurchaseRequestWorkflowConfig.javaLos estados y transiciones válidas
PurchaseRequestSummaryReportTemplate.javaEl layout del PDF
PurchaseRequestService.javaDónde se escriben las tuplas y se orquesta todo lo demás
web/PurchaseRequestController.javaLos endpoints REST
db/changelog/app/001-purchase-request.xmlEl changeset de la tabla propia

Y en el build.gradle.kts de tu proyecto:

build.gradle.kts
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):

src/main/resources/db/changelog/app/changelog-master.xml
<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:

ObjetivoEstado
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.