← Documentation
Store vs Admin API & Access
This page documents the split between the storefront API and the admin API, the admin:access permission, JWT audience/issuer, guards, and how the frontends use the new paths.
1. The issue we discussed
We needed to clearly separate:
- Storefront — used by the main website: customers browse products, log in with Google, place orders, manage favorites and profile. These should only talk to store endpoints and get tokens that are valid for the store only.
- Admin portal — used by staff: manage users, products, orders, settings, etc. These should only talk to admin endpoints and require a special permission plus tokens that are valid for the admin API only.
Without this split, a single API base and single token type meant:
- Harder to lock down admin routes (e.g. by audience and permission).
- Risk of using the same token for both store and admin.
- No clear way to “default-deny” admin routes (every admin route should require an explicit permission).
So we agreed on:
- Path split — Backend exposes two prefixes:
/store/*and/admin/*. No shared controllers; store and admin each have their own auth, products, orders, etc. - Admin-only permission — A permission
admin:access(category “Admin Portal”) that is required to use any admin route (except login/register/Google/callback/forgot/reset). Only Admin and Super Admin roles get it; Customer never does. - JWT audience and issuer — When we issue a token, we set
audto'store'or'admin'andissfrom config. The store only accepts tokens withaud: 'store'; the admin only accepts tokens withaud: 'admin'. - Admin guard — For any request to
/admin/*, we allow only specific auth paths without a user; all other/admin/*routes require a valid user,aud === 'admin', and the user must haveadmin:access. - Controller-level permission guard — On admin, we use a guard that reads
@RequirePermission()from the controller/handler. If no permission is set for an admin route, we deny by default (403). - Frontends — Main website uses the store base URL for all API calls. Admin portal uses the admin base URL for all API calls.
We did not implement a dedicated “Access denied” page or a GET /admin/auth/me check after login (that can be added later if you want to show a friendly message when someone has no admin:access).
2. Summary of the discussion
- Goal: Separate store and admin APIs by path, token audience, and permission so that store users cannot call admin endpoints and admin access is permission-based and default-deny.
- Approach: Two route prefixes (
/store,/admin), separate auth controllers and strategies (e.g. Google for store vs Google for admin), JWTaud/iss, an AdminGuard for/admin/*, and a PermissionsGuard with@RequirePermission()and default-deny on admin. Frontends were updated so the main website uses the store base and the admin portal uses the admin base for every request.
3. What was implemented (simple terms)
Backend
- Seed
- New permission:
admin:access(category “Admin Portal”). Assigned to Admin and Super Admin roles; never to Customer.
- New permission:
- JWT
- On issue, we set
audto'store'or'admin'andissfrom config (e.g.JWT_ISSUERorAPI_URL). Store login/Google use'store'; admin login/Google use'admin'. Register (store only) uses'store'. - The JWT strategy validates
issand passesaudon the user object so guards can check it.
- On issue, we set
- AdminGuard
- For
/admin/*: allow only these without a user: login, register, Google, Google callback, forgot-password, reset-password. All other/admin/*require: valid user,user.aud === 'admin', anduser.permissionsincludesadmin:access.
- For
- PermissionsGuard
- Reads
@RequirePermission()from controller and handler (handler overrides). For admin routes, if no permission is set → 403 (default-deny).
- Reads
- Path split
- Store: All store routes live under
src/store/: store auth, products, orders, categories, profile (users/me + shipping), favorites, settings, media, vouchers. - Admin: All admin routes live under
src/admin/: admin auth, dashboard, users, roles, products, orders, categories, inventory, settings, vouchers, media, email-logs, jobs. - No shared controllers between store and admin; domain modules (e.g. products, orders) only export services; store and admin controllers call those services.
- Store: All store routes live under
Main website (store frontend)
- Config
storeApiBaseUrl = apiBaseUrl + '/store'.
- API client
getApiBaseUrl()returnsstoreApiBaseUrl, so allapi.get/post/put/deleteand profile, favorites, orders go to/store/*.
- Other
- Settings fetch (
/settings/public), about page, shop page (products list, product by slug, image URLs) usestoreApiBaseUrlso every request and image URL goes through the store base. Payment proof upload uses the same base (no double/store).
- Settings fetch (
Admin portal
- Config
adminApiBaseUrl = apiBaseUrl + '/admin'.
- API client
getApiBaseUrl()returnsadminApiBaseUrl. Allrequest()anduploadFile()useadminApiBaseUrl, so every admin request goes to/admin/*.
- Layout
data-api-baseandwindow.__API_BASE__are set toadminApiBaseUrlso any script using that gets the admin base.
4. Flows now
Store (main website)
- Base URL: All requests use
apiBaseUrl + '/store'(e.g.https://api.example.com/store). - Login: User goes to main site → Login → email/password or “Sign in with Google”. Request goes to
/store/auth/loginor/store/auth/google. Token issued withaud: 'store'. - Browsing: Products, categories, landing data come from
/store/products,/store/categories, etc. - Profile, favorites, orders: Calls to
/store/profile/shipping,/store/favorites,/store/orders/me, etc., with the store token. Backend accepts only tokens withaud: 'store'. - Checkout: Quote, voucher validate, checkout, payment proof upload go to
/store/orders/...,/store/vouchers/validate,/store/media/....
Admin portal
- Base URL: All requests use
apiBaseUrl + '/admin'(e.g.https://api.example.com/admin). - Login: Staff goes to admin app → Login → email/password or “Sign in with Google”. Request goes to
/admin/auth/loginor/admin/auth/google. Token issued withaud: 'admin'. - After login: Every other request (dashboard, users, products, orders, settings, media, etc.) goes to
/admin/.... The AdminGuard checks: valid user,aud === 'admin', andadmin:accessin permissions. The PermissionsGuard then checks the specific@RequirePermission()for that route; if none is set, the request is denied (403). - Who can use admin: Only users whose role has the
admin:accesspermission (Admin and Super Admin in the seed). Customers never get this permission, so they cannot use admin even if they have a token.
Summary table
| Who | Frontend | API base | Token aud | Permission needed for non-auth |
|---|---|---|---|---|
| Customer | Main website | apiBaseUrl + '/store' | store | N/A (store routes are per-route) |
| Staff | Admin portal | apiBaseUrl + '/admin' | admin | admin:access + route-specific @RequirePermission() |
This keeps store and admin clearly separated by URL, token audience, and permission, with admin default-deny for any route that does not declare a required permission.