Documentation

Multi-tenancy

orgid header, principals, JwtAuthGuard, RBAC, and API keys — with worked examples.

Every AppEngine call is scoped to one organization. The scoping is enforced by a global guard plus an orgid header — there is no API surface that ignores it.

The orgid header

orgid is a required HTTP header on essentially every endpoint. It identifies the tenant. AppEngine uses it to scope queries, validate principals, and route data to the right collection partition.

curl https://appengine.appmint.io/profile/whoami \
  -H "orgid: your-org-id" \
  -H "Authorization: Bearer <jwt>"

If you forget the header, public endpoints sometimes still work — but anything that touches a tenant collection returns 400. If you send the wrong orgid, you get an empty result set, not someone else's data. The boundary is server-side.

A handful of unauthenticated endpoints (storefront browse, gift-card balance check, public contact form) accept orgid without a JWT or API key — they're explicitly marked @PublicRoute() in the controllers.

Principals: User vs Customer

AppMint splits "people who run the app" from "people who use the app".

PrincipalWhoSign-in endpointRefresh
UserStaff, operators, adminsPOST /profile/signin (alias POST /user/signin)POST /user/refresh
CustomerEnd-buyers, app usersPOST /profile/customer/signinPOST /customer/refresh

A SaaS dashboard's signed-in account is a User. The customer of that SaaS who buys a product is a Customer. Endpoints expect one or the other; mixing them up is the most common integration mistake.

Sign-in returns a JWT and a refresh token:

POST/profile/signinNo auth
curl https://appengine.appmint.io/profile/signin \
  -H "orgid: your-org-id" \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]", "password": "..." }'
{
  "token": "eyJhbGciOi...",
  "refreshToken": "eyJhbGciOi...",
  "user": { "pk": "...", "sk": "...", "data": { "email": "[email protected]", "roles": [...] } }
}

Subsequent calls send the JWT as Authorization: Bearer <token>.

Magic-link and OAuth alternatives

Password is one of several sign-in flows. Others share the same end state — a JWT for the principal:

GET/magic-linkNo auth
POST/magic-link/redirectNo auth
GET/user/magic-linkNo auth
POST/user/magic-link/redirectNo auth

Magic-link issues a one-time email link or 6-digit code. OAuth is wired for GitHub, Google, Facebook, Microsoft via Passport strategies — kick off at /profile/{provider} and handle the callback at /profile/{provider}/redirect.

How the guard works

JwtAuthGuard is registered globally as APP_GUARD. Every route runs through it unless decorated @PublicRoute(). The guard:

  1. Reads Authorization: Bearer <jwt> and validates the signature.
  2. Resolves the principal — currentUser or currentCustomer — and attaches it to the request.
  3. Checks @RequirePermissions(...) decorators against the principal's content permissions.
  4. Checks @Roles(RoleType.*) decorators against the principal's roles.
  5. Lets specific exceptions through — owners can always update their own records, repository owner-checks short-circuit when data.author matches the principal.

A route's decorators tell you exactly what access it requires. Example from users.controller.ts:

@Get('org/:orgid')
@RequirePermissions(PermissionTypeContent.read)
@Roles(RoleType.RootSystem, RoleType.RootAdmin, RoleType.RootPowerUser)
async getOrgUsers(@Headers('orgid') orgId: string, ...) { ... }

This route requires the principal to have read content permission AND one of the three RoleType.* values. Both checks must pass.

Roles

Built-in role types include:

  • RootSystem — internal/system identity (rare in tenant-facing code).
  • RootAdmin — full admin within an org.
  • RootPowerUser — admin minus a few destructive ops.
  • ConfigAdmin — settings, billing, plan management.
  • ContentAdmin — pages, products, content collections.
  • User — generic staff role.

Custom roles can be created per-org. Roles are stored on user.data.roles as an array.

Permissions

Permissions are content-level: create, read, update, delete. They live on user.data.permissions.content[]. The @RequirePermissions decorator declares what a route needs:

@Post('contact')
@RequirePermissions(PermissionTypeContent.create)
async createContact(...) { ... }

If the principal's permissions.content array doesn't include 'create', the guard returns 403.

API keys

API keys are an alternative to JWT for long-lived clients (servers, build pipelines, browser extensions). Send the key as x-api-key instead of (or alongside) Authorization. The orgid header is still required.

POST/api-key/createJWT
GET/api-key/listJWT
POST/api-key/regenerate/{keyId}JWT
DELETE/api-key/{keyId}JWT

Create one from the admin or via API:

curl https://appengine.appmint.io/api-key/create \
  -H "orgid: your-org-id" \
  -H "Authorization: Bearer <jwt>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "build pipeline",
    "scopes": ["read", "write"],
    "rateLimit": { "requestsPerMinute": 60 },
    "expiresAt": "2027-01-01T00:00:00Z"
  }'

The plaintext key is returned once, in the create response. List/get endpoints show metadata only.

Use it like this:

curl https://appengine.appmint.io/repository/find/contact \
  -H "orgid: your-org-id" \
  -H "x-api-key: amk_..." \
  -H "Content-Type: application/json" \
  -d '{ "query": {}, "options": { "pageSize": 50 } }'
Rotate keys on suspicion

Keys grant access for as long as they live. Rotate via POST /api-key/regenerate/:keyId if a key leaks; the old value stops working immediately.

Worked example: a request top to bottom

Goal: list contacts as a staff user with read permission.

FieldTypeDescription
orgid*headerThe tenant identifier.
Authorization*headerBearer <jwt> — JWT issued by /profile/signin.
x-api-keyheaderAlternative to Authorization. Sent on its own.
curl https://appengine.appmint.io/repository/find/contact \
  -H "orgid: my-org" \
  -H "Authorization: Bearer eyJhbGciOi..." \
  -H "Content-Type: application/json" \
  -d '{ "query": {}, "options": { "pageSize": 20 } }'

What happens server-side:

  1. Express receives the request. JwtAuthGuard decodes the token, verifies the signature, looks up the user by sk.
  2. The user is attached as request.currentUser.
  3. The route's @RequirePermissions(read) is checked against user.data.permissions.content.
  4. The route handler calls RepositoryService.find('contact', orgId, query, options).
  5. The repository scopes the query by orgId and returns matching BaseModel<Contact> documents.
  6. The response is serialized and returned with usage metering applied.

A 401 means the JWT is missing, expired, or malformed. A 403 means it's valid but lacks the role/permission. A 400 with orgid_required means the header was missing. Empty data means everything worked, you just don't have any matching records.

Where to go next

  • Data modelBaseModel\<T\> and how the repository layer works.
  • Security — JWT signing, encryption, OAuth credential handling.