Ascenda Wiki
Ascenda · orientation

Architecture

Two deployables and one database. An Expo app that holds all of the product’s state, a NestJS service that holds all of its truth, and Clerk in the corner owning the credential and nothing else.

2 deployables 1 database 5 controllers 4 global guards
the shape

Where a request goes

The app never talks to Postgres and never mints a session of its own. It asks Clerk for a token, puts that token on every call to core-api, and treats whatever comes back as the truth. The service does the reverse: it trusts the token’s signature and nothing else in it, resolving the subject into a user row — with roles read from the database — before a handler ever runs.

That split is why a role change takes effect on the next request rather than the next login, and why a Clerk user who has never made a call still has a row here: the user.created webhook writes it.

ASCENDA-APP Expo 54 / Router 6 MobX stores services/*Api.ts CLERK JWT + JWKS webhooks sign in token Authorization: Bearer CORE-API · NESTJS 11 1ThrottlerGuard 60 / 60s 2ClerkJwtGuard verify against JWKS 3UserResolutionGuard subject → user id + roles 4RolesGuard @Roles, from the database controller → service → Prisma transactions · onboardings · budget · users · webhooks user.created / updated / deleted PostgreSQL · Prisma 7 + adapter-pg
Clerk is deliberately outside the box. It authenticates; it does not authorize, and it holds no product data — the webhook exists so that the user row is ours, and roles stay in a table we can join.
the pieces

What each part is responsible for

ascenda-app

Expo, and all of the state

Expo SDK 54 with Router 6 file routing, split into (public) and (protected) groups. State is MobX singletons per domain — onboarding, transactions, statement import, budget. Forms are react-hook-form with Yup. Every network call goes through src/services/*Api.ts, which read one base URL from the environment.

core-api

NestJS, and all of the truth

Five controllers over a Prisma client bound to Postgres through @prisma/adapter-pg. Validation, error shaping and logging are global. Swagger is served at /api and is the contract of record; the API page is the readable version of it.

Clerk

The credential, and only that

Issues the JWT the app carries; the service verifies it against JWKS. Product identity lives here: authSubject on the user row is the join. Roles are ours, in UserRoleAssignment, because an identity provider should not be able to grant staff access.

Postgres

One database, twenty-five models

Migrations run through prisma migrate dev — never db push, which drifts the migration history. Lookup tables are seeded by UUID so a step or a category can be referenced from code without a lookup. The shape is on the data model page.

conventions

Two rules that are easy to break

English codes, Portuguese display. Every identifier, category code, enum value and database key is English. Only the strings a user reads are Portuguese. A category code invented in Portuguese has to be corrected in the seed, the backend and the app at once — so it does not get invented.

Every transaction has an owner. merchant_id is NOT NULL. That single constraint is what forces resolution to always end somewhere, creating a merchant on a miss rather than writing a null. The Merchant Ladder is the whole mechanism.

Two design systems, on purpose: the app keeps Poppins and the blue→purple gradient; documents like this one use Ledger. Product identity and explanatory identity are different jobs.