REST API for Rolemaster Unified Core services.
This project is part of RMU Online: https://github.com/labcabrera/rmu-platform
|
Warning
|
This application is an independent project developed by fans of Rolemaster Unified. It is not affiliated with, endorsed by, or licensed by Iron Crown Enterprises (ICE), the owners of the Rolemaster intellectual property. All Rolemaster trademarks, game systems, and materials are the property of Iron Crown Enterprises. This software is provided for personal, non-commercial use only. If you enjoy Rolemaster, please support the official publications and content from ICE. |
-
Node.js 24 (
.nvmrc;nvm use). -
pnpm 10, pinned by the
packageManagerfield ofpackage.json.corepack enableinstalls that exact version. -
Docker (20.10+) for the infrastructure and the image.
The API connects to MongoDB, Kafka and the Keycloak JWKS at startup. Start them from
rmu-platform with make up-infra (Compose files in compose/).
Configuration is read from .env at the repository root. Copy .env.example, which already points to the
infrastructure ports seen from the host. Set RMU_IAM_CLIENT_SECRET there only to run scripts/smoke.sh:
cp .env.example .envThe Joi schema in src/config/env.schema.ts fails startup on a missing required variable.
| Variable | Required | Purpose |
|---|---|---|
|
no ( |
HTTP port |
|
no ( |
Nest logger level |
|
yes |
MongoDB connection string |
|
yes |
JWT validation |
|
no ( |
browser-facing OAuth URLs of the Swagger UI ( |
|
yes |
Kafka connection |
|
no ( |
consumer group of the realm events that keep the races in step |
|
no ( |
number of partitions the producer spreads messages over (by key hash) |
|
no (empty) |
comma-separated origins allowed by CORS; empty turns CORS off, as browsers come through the gateway on the same origin |
|
no ( |
requests per client IP and window; |
|
no ( |
Express |
| Command | What it does |
|---|---|
|
Nest in watch mode (with the inspector for |
|
SWC build to |
|
|
|
ESLint (typed |
|
Prettier over |
|
unit tests ( |
|
unit and e2e tests with v8 coverage in |
|
one file per resource booting |
|
|
|
lint + typecheck + |
|
|
Tests run on Vitest with SWC (vitest.config.ts, projects unit and e2e). The first test:e2e run downloads a
mongod binary to ~/.cache/mongodb-binaries, once, in the globalSetup of the e2e project
(test/e2e/support/mongod-binary.ts): without it, the files would download it in parallel and fail on the lock file.
scripts/smoke.sh [base-url] calls one endpoint of every controller of a running API with a service-account token and
fails on any non-2xx answer (default http://localhost:3001; through the gateway http://localhost/api/core).
pnpm skips the install scripts of @scarf/scarf, @swc/core and mongodb-memory-server (pnpm.ignoredBuiltDependencies):
none of them is needed, and mongodb-memory-server downloads mongod on first use.
src/
main.ts bootstrap only: HTTP setup, OpenAPI, Kafka transport; a failed start exits with code 1
bootstrap/ HTTP setup (trust proxy, helmet, CORS, validation, error filter), logger levels, OpenAPI
config/env.schema.ts Joi schema of the environment
modules/<feature>/
<feature>.module.ts
domain/
aggregates/ aggregate roots with a lifecycle (update, delete, events) and their props
entities/ immutable catalog entities (armor-types, skill-categories)
value-objects/, services/, events/
application/
cqrs/{commands,queries,handlers}/
ports/ <entity>-repository.port.ts, <entity>-guard.port.ts: interface + DI token
infrastructure/
db/ mongo.<entity>.repository.ts, in-memory.<entity>.repository.ts
persistence/models/ <entity>.model.ts (Mongoose schemas)
security/ <entity>-guard.adapter.ts (read/write access checks)
interfaces/http/ <entity>.controller.ts
dto/ request and response DTOs
interfaces/messaging/ Kafka consumers (races: realm events)
modules/shared/
application/ ports (base repository, entity guard, DomainEventPublisher), criteria, pagination (Page)
domain/ base aggregate, domain events, DomainError, access types and roles
infrastructure/db/ MongoBaseRepository, RsqlParser, MongoQueryCriteriaMapper
infrastructure/messaging/ KafkaProducerService, KafkaDomainEventPublisher
infrastructure/security/ JWT strategy and guard, base entity guard, AuthModule
interfaces/http/ DomainError -> HTTP filter, @CurrentUser(), @ApiErrors(), health, shared DTOs
test/unit/domain/<feature>/ unit tests of each feature
test/unit/shared/ unit tests of the shared module
test/e2e/<resource>.e2e-spec.ts the whole application against mongodb-memory-server, one file per resource
test/e2e/support/ local JWKS and tokens, startApp(), request body fixtures
Conventions:
-
The package is ESM (
"type": "module"). Imports are relative and end in.js('./races.module.js'); there is nosrc/…alias.verbatimModuleSyntaxrequiresimport typefor anything that is only a type, but a class used as a constructor parameter must stay a value import, because Nest reads it from the decorator metadata. -
File names are kebab-case with a dotted suffix for the role (
.module,.controller,.dto,.command,.query,.handler,.port,.repository,.model,.adapter). A file holds one command, query or handler and is named after it (SyncRaceRealmCommand→sync-race-realm.command.ts, its handlersync-race-realm.handler.ts). Adapters tied to a technology start with it:mongo.race.repository.ts. -
Each port exports its DI token next to the interface (
export const RACE_REPOSITORY = Symbol('RaceRepository')). Modules bind it ({ provide: RACE_REPOSITORY, useClass: MongoRaceRepository }) and handlers inject it with@Inject(RACE_REPOSITORY). There are no string tokens, and no abstract classes as tokens either: an API that still uses them (users) converts them to aSymbolnext to the interface when it touches the port (spec 021). -
Controllers take the user with
@CurrentUser() user: AuthenticatedUserand document their errors with@ApiErrors({ 404: 'Race not found' }), which always adds the 401. -
domain/never imports fromapplication/. A read-only module may skipdomain/(character-sizes). -
Handlers publish domain events through
DomainEventPublisher(DOMAIN_EVENT_PUBLISHER) after saving:publishChanges('race', race)for the aggregate’s uncommitted events,publish('race', event)for one built by hand. Publishing awaits Kafka (with retries) and logs a failure instead of failing the request. Modules react to each other’s changes through these topics, like any other service, and never import each other’s commands. The eventproducerisRMU_KAFKA_CLIENT_ID(rmu-api-corewhen unset). -
Writes without changes.
BaseAggregateRootkeeps the stored version in an ECMAScript private field (#storedVersion, never serialized) and, inrestoreVersion(), a snapshot ofgetProps(). The repository calls it when it loads the aggregate (toEntity, also from custom finders) and after each write, including on the aggregate passed toupdate(), which can then be written again.hasChanges()compares the props withoutupdatedAtwith that snapshot; an aggregate made withcreate()always has changes. Every update handler applies the changes and returns the aggregate unchanged when!aggregate.hasChanges(): no write, no new version, no event. Aggregates setupdatedAtonly whenhasChanges()is true. -
The build uses SWC (
nest build), which compiles file by file and cannot resolve imported type aliases in decorator metadata. A property typed with a string union alias (AccessType) needs an explicittype: Stringin@ApiPropertyand@Prop(alsostring | nullornumber | undefined, which SWC documents asobject), and constructor injection must not use a bare@Inject()(Nest would readFunctionas the token). Neither lint nor typecheck sees them, only booting the app does:pnpm check:swc(inmake checkand the CI) finds both.pnpm typecheckrunstsc --noEmitseparately. -
armor-typesandcharacter-sizesare fixed catalogs served from memory, andresistance-rollsandmaneuversare calculations: they have no aggregates, persistence or events, and that is on purpose. A module only gets the full CQRS and Mongo layout when its data can change. -
Permissions. The guards (
<entity>-guard.adapter.ts) extendBaseEntityGuard:rmu-userorrmu-admincreates, apublicentity is read by everyone and aprivateone by its owner and admins, and only the owner or an admin changes or deletes it.-
Entity source (spec 063): cultures, effect types, enumerations, maneuver modifiers, professions, races, realms, skills and traits carry
entitySource: 'system' | 'user'. Asystem(rulebook) entity is changed or deleted, also through its sub-resources, only by an admin: anyone else gets 403system-entity-admin-only, its owner included. The rule lives inshared/domain/services/entity-permissions.ts, whichBaseEntityGuardandTraitGuardAdaptercall. Create and update DTOs take an optionalentitySource: only an admin sendssystem, and a missing value isuseron create. A document stored without the field is read asuser. -
Deleting a realm cascades to its races (Kafka,
DeleteRacesByRealmCommand, a system command without access check). So a realm with at least onesystemrace is deleted only by an admin: 403system-entity-admin-onlyfor its owner.DeleteRealmHandlercounts those races throughREALM_RACE_QUERY(a realms port whose adapter reads theracescollection, since the races module imports the realms one and not the other way round).Exceptions: * traits are the only public resource without `accessType` (`TraitGuardAdapter`): every authenticated user reads and lists all of them, because races and characters of any user reference them; * a race, or an enumeration with a `realmId`, is created only by who can change that realm (`REALM_GUARD`, exported by `RealmsModule`): 400 if the realm does not exist, 403 if it belongs to someone else; * skill categories are a catalog without owner: only `rmu-admin` creates them, a check that lives in the handler.
-
The numbers of the rulebook tables are not in this repository (it is public). healing-tables and
psychology-tables read one document (_id: core-law) from the collections of the same name, and they answer 404
until it has been imported; both need the rmu-core-law group (or rmu-admin). The maneuver profiles of skills and
categories, the maneuver modifiers and the stored absolute maneuver tables come from the same place. The JSON files
and the import scripts live in the workspace data/ directory, outside every git repository, and are loaded by hand
into the local rmu-mongo container from the workspace root (DRY_RUN=1 only prints what would change):
data/scripts/run.sh data/scripts/import-healing-tables.js data/core-law/healing-tables.json
data/scripts/run.sh data/scripts/import-psychology-tables.js data/core-law/psychology-tables.json
data/scripts/run.sh data/scripts/import-maneuver-profiles.js data/core-law/maneuver-profiles.json
OWNER=<keycloak-user-id> data/scripts/run.sh data/scripts/import-maneuver-modifiers.js data/core-law/maneuver-modifiers.json
data/scripts/run.sh data/scripts/import-skill-difficulties.js data/character-companion/skill-difficulties.json-
GET /v1/healing-tablesalso returnstreatmentDifficulties(procedure,injuryType,severityand the difficulty key of a Medicine procedure; no row means the procedure does not apply) andtreatmentRules(head injuries one step harder, cauterize penalty per point of bleeding, procedures that need bleeding, tourniquet locations). A document imported before them returns no rows and neutral rules: import it again. -
GET /v1/psychology-tablesreturns the fright levels, the fear states by failure margin, the morale and rally tables and the resistance roll table, all as numbers and result keys. -
The maneuver profile has a
resultKind:standard(theresultTableor the percent table),morale(the morale and rally tables, Leadership) orprice(an opposed percent maneuver whose result is a price, Trading). -
GET /v1/skill-difficulties/{skillId}returns the example difficulties of a skill (Character Companion), one text per difficulty key inenand, when translated,es. A skill with no guide gets thegeneralscale withfallback: true, and the endpoint answers 404 only whengeneralitself has not been imported.GET /v1/skill-difficultiesreturns every guide. Both needrmu-core-law(orrmu-admin), like the other tables. -
specializationOptionalon a skill means the specialization may be left empty (the crafting skills); without it, a skill with aspecializationcategory needs one.
-
Validation: the global
ValidationPipeuseswhitelist, so unknown body fields are dropped without an error (noforbidNonWhitelisted). A client sending extra fields, like theownerof an entity, is not rejected; the field is ignored. -
Search:
qis RSQL (==,!=,<,⇐,>,>=,=in=,=out=,=re=,;for and,,for or).=re=is a case-insensitive contains over the literal text, not a regular expression: the value is escaped, soname=re=firefindsFireballbutname=re=^Firelooks for the characters^Fireand finds nothing. -
Paging:
sizegoes up to 100 (PagedQueryDto). Skills, skill categories, enumerations and traits, which the clients load whole, accept up to 1000 (CatalogPagedQueryDto). -
Errors: every failure answers
{ statusCode, error, code, message, timestamp, path }.codeis stable and meant for clients:not-found,forbidden,unauthorized,validation,bad-request,invalid-search,conflict,duplicate-key,version-conflict,too-many-requests,internal-error… Domain errors take a default code from their class and can pass a more specific one (new ConflictError(message, 'duplicate-key')).pathis the route without the query string (/v1/realms, not/v1/realms?size=101). -
OpenAPI: every operation is documented with the
oauth2andaccess-tokensecurity, except the public/v1/health. -
Optimistic concurrency: every stored aggregate has a
version(0 when created, +1 on each write), returned in the responses.MongoBaseRepository.updateonly writes when the stored version is still the one that was loaded, so two concurrent handlers cannot overwrite each other. APATCHmay send theversionits client read: if the entity moved on, the answer is 409version-conflictand nothing changes. Without it, the update is applied to the current version (image changes do that). Documents stored before versions existed count as version 0. APATCHthat leaves the entity as it was answers 200 with the stored entity, the sameversionand no event. -
Request limit:
RMU_THROTTLE_LIMITrequests per client IP andRMU_THROTTLE_TTL_MSwindow (429 withRetry-After);/v1/healthand the Kafka handlers are never limited.
make docker-build builds labcabrera/rmu-api-core:latest and make docker-run runs it on the rmu-network Docker
network (needs .env.docker, copied from .env.docker.example). The image (node:24-bookworm-slim, pnpm through
corepack) only holds dist/ and the production dependencies, runs as the node user with NODE_ENV=production and has
its own HEALTHCHECK on /v1/health. Run it with --init (init: true in compose) so SIGTERM reaches Node and Nest
closes Kafka and Mongo.
make docker-push builds linux/amd64 and linux/arm64 without emulation: every RUN step happens on the builder’s
platform, and supportedArchitectures makes pnpm install the optional native binaries of the target platform.
The whole stack (APIs, MFEs, gateway, infrastructure) runs from the Compose files of rmu-platform.
make create-release runs semantic-release from a clean develop: it merges into master, derives the version from the
Conventional Commits (force one with RELEASE_BUMP=patch|minor|major), tags it without v, updates CHANGELOG.md, and
bumps develop to the next -SNAPSHOT. Commit messages are checked by commitlint in a husky hook.
rmu-api-core is the reference for the other NestJS APIs (spec 014). To bring an API to the same platform, copy these
files and adapt names, ports and the Mongo variable:
| File | What it sets |
|---|---|
|
|
|
SWC builder ( |
|
|
|
Vitest + |
|
typed |
|
|
|
CommonJS configs in an ESM package |
|
pnpm install, lint, typecheck, |
|
the SWC decorator check ( |
|
|
|
short bootstrap with shutdown hooks, logger levels, OpenAPI and the Joi schema |
|
JWT strategy and guard, |
|
smoke test with |
Run the API with init: true in compose. The SWC caveats under Project layout apply to every API: an explicit
type: String for string union aliases in @ApiProperty/@Prop, and no bare @Inject(); pnpm check:swc finds
both.
Core calls no other API, so it has no HTTP client. The APIs that do (items, strategic, tactical, users) share two files that are not in core:
-
shared/infrastructure/http/http-client.ts:fetchwith a timeout (RMU_HTTP_TIMEOUT_MS, 10000 by default). A timeout is a 504upstream-timeout, a network failure a 502, an upstream 400/422 a 400 and an upstream 404/5xx a 502, the last three with the upstreamstatus(isUpstreamHttpError(err) && err.status === 404to tell a missing entity). No axios. -
shared/infrastructure/security/token.service.ts: the token of the call. It is the service account token (client credentials, cached until it expires, withrmu-admin); tactical forwards the caller’s own token instead while it handles an HTTP request, so the other API checks that user’s permissions.

