Identity + tenancy service for the Sisques Labs platform (Gardenia, Nexora, and future apps). It owns:
- User — the platform identity record (
id,email,displayName,platformAdmin). - Auth — registration/login backed by Keycloak (self-hosted, shared via
local-dev-stack), and Sisques Account's own signed JWT (access token) + opaque refresh token (its ownSessionaggregate). Apps never talk to Keycloak directly — onlyaccount-apidoes. - Tenancy — the platform-level mechanics of a tenant (name, members, roles). What each role means inside a given app (e.g. Gardenia's "member" can water but not delete plants) is that app's own concern, not this service's.
See /Users/javi/Documents/projects/sisques-labs/sisques-account-architecture.md
for the full design. This repo currently implements the MVP: account-api
standalone, validated via tests/Postman — no account-web frontend yet, no
email-based tenant invites.
Built from sisques-labs/nestjs-template
— DDD + CQRS + Hexagonal architecture. Three bounded contexts:
src/contexts/user/, src/contexts/auth/ and src/contexts/tenancy/ — see
each context's own README.md for the one/two/three-context decision,
aggregates, and public API.
- Node (see
.nvmrc) +pnpm(seepackageManagerinpackage.json) - Docker + Docker Compose v2
local-dev-stackrunning, for the shared Postgres and Keycloak — see below
1. Shared Postgres + Keycloak (local-dev-stack)
account-api uses local-dev-stack's shared Postgres instance and shared
Keycloak instance rather than spinning up its own — see "Keycloak — where
it runs" below for why. account_db is already registered in that repo's
docker/postgres/init-db.sh, and the sisques-account realm + account-api
client are registered as
local-dev-stack/docker/keycloak/realms/account-api-realm.json. Start (or
reuse) the stack:
cd ../local-dev-stack
docker compose up -dIf the stack was already running from before this database was added,
init-db.sh won't retroactively create it (it only runs on first boot of an
empty volume) — create it by hand instead:
docker compose exec postgres psql -U devuser -d postgres -c "CREATE DATABASE account_db;"Similarly, if the stack was already running from before the
account-api-realm.json file was added, re-run the import job by hand
instead of waiting for a fresh boot:
docker compose up -d keycloak-realm-importKeycloak admin console at http://localhost:8084 (admin / admin, local
dev only — see local-dev-stack's README "Keycloak" section). The service
account (manage-users/view-users/query-users on realm-management)
isn't part of the imported realm JSON (that combination crashes Keycloak's
import on this version — see local-dev-stack's README); grant those role
mappings once by hand in the admin console: Users →
service-account-account-api → Role mapping → Assign role → filter by
clients → realm-management.
2. The app
pnpm install
cp .env.example .env # defaults already point at local-dev-stack's shared
# Postgres and Keycloak — see .env.example
pnpm devMigrations run automatically on boot (DATABASE_MIGRATIONS_RUN defaults to
true) and create the 4 MVP tables: app, user, tenant,
tenant_membership.
Decision: Keycloak lives in local-dev-stack, not in this repo's own
docker-compose.yml. It used to be kept here on the reasoning that it had
exactly one consumer (account-api is the architecture doc's one explicit
exception to "apps never talk to Keycloak directly" — it is the adapter
boundary), mirroring how this template keeps its own otel-collector/Jaeger
alongside local-dev-stack's shared Postgres. That's no longer true: more
than one service now needs Keycloak, so it's centralized in
local-dev-stack the same way Postgres already was, with each service
registering its own realm/client under
local-dev-stack/docker/keycloak/realms/ (this repo's is
account-api-realm.json).
pnpm test # unit (mocked, no infra needed)
pnpm test:db:up # postgres-test (5433) + keycloak-test (8082)
pnpm test:integration # persistence boundaries, real Postgres
pnpm test:e2e # full HTTP flows — real Postgres AND real Keycloak
pnpm test:db:downdocker-compose.test.yml provisions an isolated keycloak-test (same
realm-export.json, port 8082) purely so e2e specs can exercise the real
KeycloakIdentityProviderAdapter for register/login — separate from the
shared dev Keycloak on 8084 (local-dev-stack) so both can run at once.
This test-only container still uses Keycloak's own --import-realm flag
(see docker-compose.test.yml / .github/workflows/ci.yml) rather than
the REST-API-based import local-dev-stack uses for dev — it hasn't hit
the startup crash local-dev-stack's README documents, but if it ever
does, apply the same fix there.
Register → login → create an app → create a tenant (creator becomes owner) → add an existing user as a member → list members → refresh.
BASE=http://localhost:3000/api/v1
# 1. Register the tenant creator
curl -s -X POST $BASE/auth/register -H 'Content-Type: application/json' -d '{
"email": "owner@example.com", "password": "Sup3rStrongPassw0rd!", "displayName": "Owner"
}'
# 2. Log in — returns { accessToken, refreshToken } (also set as cookies)
curl -s -X POST $BASE/auth/login -H 'Content-Type: application/json' -d '{
"email": "owner@example.com", "password": "Sup3rStrongPassw0rd!"
}'
# -> save accessToken as $TOKEN, refreshToken as $REFRESH
# 3. Register the app (bootstrapping plumbing — no MVP endpoint touches `app`
# otherwise, but `tenant.app_id` is a required FK)
curl -s -X POST $BASE/apps -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{
"slug": "gardenia", "name": "Gardenia"
}'
# -> save appId as $APP_ID
# 4. Create a tenant — $TOKEN's user becomes owner automatically
curl -s -X POST $BASE/tenants -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{
"appId": "'$APP_ID'", "name": "My Garden"
}'
# -> save tenantId as $TENANT_ID (slug defaults to "my-garden" when omitted)
# 5. Register a second user to add as a member
curl -s -X POST $BASE/auth/register -H 'Content-Type: application/json' -d '{
"email": "member@example.com", "password": "Sup3rStrongPassw0rd!", "displayName": "Member"
}'
# 6. Add them as a member (by email — no invite flow in the MVP)
curl -s -X POST $BASE/tenants/$TENANT_ID/members -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{
"email": "member@example.com", "role": "member"
}'
# 7. List members — owner + the new member
curl -s $BASE/tenants/$TENANT_ID/members -H "Authorization: Bearer $TOKEN"
# 8. Refresh — rotates the refresh token, issues a new access token
curl -s -X POST $BASE/auth/refresh -H 'Content-Type: application/json' -d '{
"refreshToken": "'$REFRESH'"
}'Swagger UI at http://localhost:3000/docs documents every request/response
shape.
| Area | Where |
|---|---|
| Config + env validation | src/core/config/ (Zod), incl. auth.config.ts (JWT + Keycloak) |
| Auth infrastructure | src/core/security/ — JwtAuthGuard, @CurrentUser(), shared JwtService. Cross-cutting (used by every context), not owned by auth |
| Health checks | src/core/health/ — GET /api/health/live, GET /api/health/ready |
| Logging / OTel / MCP / Kafka forwarding | Unchanged from the template — see openspec/config.yaml and each module's own comments |
| Database | src/database/migrations/ — the MVP tables (app, user, tenant, tenant_membership, session) |
DDD + CQRS + Hexagonal. Full rules in .claude/skills/architecture/SKILL.md;
project-wide conventions in openspec/config.yaml. Context-specific design
(aggregates, cross-context ports, public API) lives in
src/contexts/user/README.md, src/contexts/auth/README.md and
src/contexts/tenancy/README.md.