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".
| Principal | Who | Sign-in endpoint | Refresh |
|---|---|---|---|
| User | Staff, operators, admins | POST /profile/signin (alias POST /user/signin) | POST /user/refresh |
| Customer | End-buyers, app users | POST /profile/customer/signin | POST /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:
/profile/signinNo authcurl 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:
/magic-linkNo auth/magic-link/redirectNo auth/user/magic-linkNo auth/user/magic-link/redirectNo authMagic-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:
- Reads
Authorization: Bearer <jwt>and validates the signature. - Resolves the principal —
currentUserorcurrentCustomer— and attaches it to the request. - Checks
@RequirePermissions(...)decorators against the principal's content permissions. - Checks
@Roles(RoleType.*)decorators against the principal's roles. - Lets specific exceptions through — owners can always update their own records, repository owner-checks short-circuit when
data.authormatches 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.
/api-key/createJWT/api-key/listJWT/api-key/regenerate/{keyId}JWT/api-key/{keyId}JWTCreate 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 } }'
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.
| Field | Type | Description |
|---|---|---|
| orgid* | header | The tenant identifier. |
| Authorization* | header | Bearer <jwt> — JWT issued by /profile/signin. |
| x-api-key | header | Alternative 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:
- Express receives the request.
JwtAuthGuarddecodes the token, verifies the signature, looks up the user bysk. - The user is attached as
request.currentUser. - The route's
@RequirePermissions(read)is checked againstuser.data.permissions.content. - The route handler calls
RepositoryService.find('contact', orgId, query, options). - The repository scopes the query by
orgIdand returns matchingBaseModel<Contact>documents. - 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 model —
BaseModel\<T\>and how the repository layer works. - Security — JWT signing, encryption, OAuth credential handling.