docs
/
Platform

Identity and access

Users, customers, API keys and service accounts — and the exact order authorization is decided in.

Two kinds of identity

AppEngine models an operator and an end user as different record types, not as one user table with a flag.

UserCustomer
Datatypeusercustomer
RepresentsStaff, admins, builders — operators of the tenantAn end user of the tenant's site or app
Sign-inPOST /profile/signinPOST /profile/customer/signin
Sign-upinvitation flow (/profile/user/invite/*)POST /profile/customer/signup
RefreshPOST /profile/user/refreshPOST /profile/customer/refresh
JWT markerno datatype, or userdatatype: "customer"
Populated on the requestcurrentUsercurrentCustomer and currentUser

CurrentUserMiddleware branches on tokenInfo.datatype. A customer token sets both currentCustomer and currentUser — the latter so downstream code that only reads currentUser still works. When the caller is a non-System user, currentCustomer is set to that same user.

Credential types

Bearer token

Authorization: Bearer <jwt>
orgid: acme

The JWT payload is the signed record itself (jwtService.sign(user)), carrying _id, datatype, and data.roles / data.permissions — minus the record's notes and data.permissions.menu, which stay on the profile the sign-in returns (see Menu access) so they do not grow every request header. Signed with jwtConstants.audience and .issuer; lifetimes come from JWT_EXPIRES_IN and JWT_REFRESH_EXPIRES_IN.

On every request the middleware re-fetches the record from the org database and copies permissions and roles from the token onto it. So a deleted user is rejected with 401 user_not_found, but a role edited after issue does not take effect until the token is refreshed.

Cookies

With no Authorization header, the middleware falls back to a token or jwt cookie (and an orgid / orgId cookie for the tenant), synthesizing Authorization: Bearer <cookie> for the rest of the pipeline. This is what lets browser clients work without touching the header.

API key

apiKey: <key>
orgid: acme

x-api-key works too. authenticateApiKey(orgId, apiKey, {}) resolves the key to its owning user, and the middleware injects that user's bearer token. Keys carry the owner's roles; ConfigAdmin or ContentAdmin sets data.admin = true, and System sets data.system = true.

Manage keys under /api-key/*. Keys can be blocklisted:

POST/profile/blacklist/apikey/addJWT
DELETE/profile/blacklist/apikey/delete/:apiKeyJWT

Service account acting for a customer

A System user can act on a customer's behalf by sending both tokens:

Authorization: Bearer <system-user-token>
x-client-authorization: Bearer <customer-token>
orgid: acme

The middleware decodes the client header, loads that customer, and sets currentCustomer. Then:

  • On PUT /repository/create, the customer's sk is stamped as author.
  • On POST/PUT/DELETE to repository/create|update|delete, JwtAuthGuard evaluates permissions against the customer, not the system user.
System writes require the client header

If a System-role user hits a repository write without x-client-authorization, the guard throws 401 — No customer found - x-client-authorization header is missing. This is deliberate: a service account is not allowed to write as itself.

Roles

Fifteen values in RoleType:

RoleScope
GuestUnauthenticated or anonymous
CustomerEnd user
UserBasic operator
OwnerTenant owner
PublisherMay publish content
ReviewerMay review and approve
PowerUserElevated operator
ContentAdminFull content administration — sets data.admin
ConfigAdminConfiguration administration — sets data.admin
SystemService account — sets data.system
AIAn AI employee's user — held on top of the groups it is given
RootUser, RootPowerUser, RootAdmin, RootSystemCross-org platform roles

Each expands into three things: a content permission set, a component permission set, and menu include/exclude lists that Studio, Business Made and the mobile app use to build navigation (see Menu access).

Content permissions (PermissionTypeContent): read, create, update, delete, review, approve.

Component permissions (PermissionTypeComponent): add, view, remove, configure.

Roles compose. At sign-in and refresh the server resolves every role the user holds — directly and through groups — and unions their content and component sets. A role saved as a userrole record in the org uses that record's permissions; a built-in with no saved record uses the SDK preset; a deleted or unknown custom role grants nothing.

Menus are the one part of a role that is not merged. The profile returned by sign-in and refresh carries one entry per resolved role under data.permissions.menu:

{
  "data": {
    "roles": ["Owner", "Publisher", "Warehouse"],
    "permissions": {
      "content": ["read", "create", "update", "delete", "review", "approve"],
      "component": ["add", "view", "remove", "configure"],
      "menu": {
        "Owner":     { "menuInclude": [], "menuExclude": [], "default": "all" },
        "Publisher": { "menuInclude": [], "menuExclude": [], "default": "maker" },
        "Warehouse": { "menuInclude": ["/inventory", "/order", "/mobile/businessmade/pos"], "menuExclude": [] }
      }
    }
  }
}
  • menuInclude / menuExclude are the lists saved on the role's userrole record (empty when nobody has saved one).
  • default is the built-in role's menu tier, for when nobody has edited its menu: all for Owner, System, RootAdmin, RootSystem and RootUser; admin for ConfigAdmin; manager for PowerUser, ContentAdmin and RootPowerUser; maker for Publisher and Reviewer; everyone for User; none for Guest and Customer. Tiers widen — maker sees everything everyone sees, and so on. The table is ROLE_MENU_DEFAULTS in the SDK (roleMenuDefault(name)).
  • A built-in with neither a saved record nor a tier (AI) has no entry.

Entries stay per role so that one role's exclude cannot remove what another role includes. Each client turns the entries into its own screens: a tier is expanded by the app against the tier each of its screens declares — the Studio sidebar, the Business Made sidebar, and the mobile app's MOBILE_APP_MENU (paths /mobile/<group>/<item>, edited on the Mobile app tab of Role & Permission › Menu access). In the Studio:

  • The allowed set is the union of every role's includes; a role whose menu is "no access" (menuExclude: ["all"] with nothing included) wins over anything another role adds.
  • The Owner and root roles, and any user in the root or shared org, see every screen.
  • Web and mobile paths are independent: a role narrowed only on the Mobile app tab has not restricted the web menu.

data.permissions.menu is not in the JWT; read it from the sign-in or refresh response. Menus decide what is shown, not what is allowed — every route still enforces its own permissions.

AI employees are users

An AI employee is a user like any operator: it signs in with the same flow — password, then the org's second factor — and what it may do is decided by its groups. Its user always holds the AI role and sits in the AI group (every new organization gets that group with its other role groups; older ones get it when their first AI employee is made). AI grants nothing beyond a plain user by itself; it marks the identity, so an AI employee can work only on its own queue through the AI employee API. See AI employees.

How a request is authorized

JwtAuthGuard.canActivate runs these checks in order and returns on the first that passes.

1. Public route @PublicRoute() on the handler or class returns true immediately — every sign-in, sign-up, OAuth callback and webhook carries it. @AuthenticatedRoute() re-closes one route inside a public controller.

2. Pick the identity Normally currentUser. But on a POST/PUT/DELETE to repository/create|update|delete by a System-role user, the customer from x-client-authorization is used instead — and its absence is a 401.

Then three refusals, before anything can grant access:

  • Staff only — on a handler or controller marked @StaffOnly(), the site's app identity (data.system, or the System role or group) and any non-user caller get 403 This needs someone signed in to the business. Operator routes a storefront must never reach use it — Content Studio's review and enrollment routes among them.
  • Locked account — a user whose data.lockout is set gets 403, before any ownership shortcut.
  • Community records — a customer may not write community_* records through the generic repository writes (403); community changes go through the community endpoints.

3. Own-profile access On a URL containing /user/profile or /profile/user, if the target record's sk equals the caller's sk, allow.

4. Record ownership On repository writes, if currentData.author matches the caller's sk, username or email, allow. CurrentUserMiddleware pre-loaded currentData precisely so this check can happen.

5. Per-record required roles If the record carries requiredRole.update (or .delete) and the caller holds one of those content permissions, allow. This is row-level security, stored on the record.

6. Decorator permissions @RequirePermissions(...) — the caller needs at least one listed content permission, or the request is denied.

7. Decorator roles @Roles(...) — the caller needs at least one. Two special cases: RootSystem in the required list passes if the user's groups include RootSystem; and a user-datatype caller passes automatically when User is in the list.

@Roles(User) alone also admits the site's app token — the identity a storefront's server proxy sends for every anonymous visitor. For an operator-only route, add @StaffOnly().

8. Token validation Only now does the Passport JWT strategy actually verify the token.

Ownership is checked before roles

Steps 3–5 can grant access without any role check running. A record's author can always update it, regardless of the @Roles on the handler. That is intentional, but it means handler decorators are a floor for non-owners, not a ceiling for everyone.

Sign-in outcomes

POST /profile/signin returns 200 in four different situations. Only one of them is a session.

{ "user": { … }, "orgId": "acme", "rootOrg": "…", "sharedOrg": "…", "token": "…", "refreshToken": "…" }
{ "requiresPasswordChange": true, "message": "…", "userId": "…", "email": "…" }
{ "requiresTwoFactor": true, "challengeToken": "…", "twoFactorMethod": "email|sms|authenticator", "message": "…" }
{ "requiresTwoFactor": true, "isNewDevice": true, "challengeToken": "…", "twoFactorMethod": "email", "message": "New device detected…" }

Device binding and 2FA

Every sign-in fingerprints the device (generateDeviceFingerprint → generateDeviceId) and checks it against the user's known devices.

  • A blocked device is 403, before any credential check matters.
  • An unknown device registers itself and — unless alertOnNewDeviceLogin is false — sends an alert email.
  • If the org sets enableNewDeviceAuthentication, an unknown device forces an email challenge even when 2FA is otherwise off.

2FA triggers when the org sets enableTwoFactorForUsers or the user enabled it themselves. Methods are email, sms and authenticator; for the first two, the code is sent before the response returns. Org-level switches live under securitySettings; per-user state under /profile/security/*.

Every attempt, successful or not, is written to login history and recorded as a user activity.

Other sign-in paths

PathEndpoint
POS passcode / NFC cardPOST /profile/signin/passcode — employeeId plus an optional 6-digit pin and cardUid. Whether the pin is required depends on the org's passcode-vs-instant setting.
Magic linkGET /profile/magic-link, POST /profile/magic-link/redirect, and /profile/user/magic-link variants
Email codeGET /profile/code/:email
GoogleGET /profile/google → /profile/google/redirect; token exchange at POST /profile/customer/google/token
FacebookGET /profile/facebook/url, /profile/facebook, /profile/facebook/redirect, POST /profile/facebook/token
GitHubGET /profile/github/url, /profile/github, /profile/github/redirect, POST /profile/github/token
GuestPOST /profile/guest/auth
GlobalPOST /profile/global-login
Shared site tokenGET /profile/user/shared-site-auth/:token

Microsoft and generic link-based sign-in are also supported.

Every auth route is mounted twice

@Controller(['profile', 'user']) means /profile/signin and /user/signin are the same handler. Where the method decorator also uses an array — @Post(['/user/signin', '/signin']) — you get four URLs. Pick one prefix per client and stay consistent.

Blocklists

BlacklistMiddleware rejects blocklisted values and API keys.

GET/profile/blacklist/getJWT
POST/profile/blacklist/addJWT
DELETE/profile/blacklist/delete/:valueJWT