Appearance
02 · AUTH — login, permissions, roles, store scope, users admin
1. Summary
AUTH decides who the caller is and what they may touch. A login issues an opaque token stored in Redis. One global AuthGuard maps each route to a permission key (<resource>.<suffix>), and that key is read from decorators at boot. The guard then checks the key against the union of the caller's roles, which come from Mongo through a Redis cache. A user's scopes.store (or the X-Store-Id header) narrows every query of an entity marked @Scoped inside BaseService. Admins manage roles and user access through /roles and /users. Boot seeds 5 system roles and backfills legacy users and applications. On the web side (store-v2), /auth/me loads once per SSR into a zustand store. That store gates the menu, pages (withPermission) and buttons (<Can>). The top bar carries the store picker and the user badge.
| Repo | Worktree | Branch | SHA | Diff base | Folders |
|---|---|---|---|---|---|
| bigc-tms-core-v2 ("core") | /Users/thanosorn/Works/Software/tms/wt-v2-auth-core | feature/auth (not merged to main-v2-prototype) | 9d9422c | 3d70475 | src/auth, src/role, src/user, src/application, src/common/{guards,permission,scope,decorators,context,base} |
| bigc-tms-store-v2 ("web") | /Users/thanosorn/Works/Software/tms/wt-v2-auth-store | feature/auth | d9fd082 | 0851a3a | src/pages/setting/{roles,users}, src/components/{Can,StorePicker,UserBadge,ui/ErrorPage}, src/stores/profile, src/utils/{permission,store_scope,menu}, src/pages/api/{roles,users,permissions,auth,user} |
Citation convention: a bare src/...:line is a core path. A web:src/...:line is a store-v2 path. Every core route is served under /api/v1/... (src/main.ts:30-35), except /health_check.
2. File map
Core
| File | Responsibility |
|---|---|
src/common/guards/auth.guard.ts | Global guard (APP_GUARD, src/app.module.ts:80): authenticates Bearer/Basic, resolves access, validates X-Store-Id, decides 401/403/pass |
src/common/decorators/permission.decorator.ts | @Resource(name) (class), @Permission(suffix, label?) (route or class), @LoggedIn() |
src/common/decorators/public.decorator.ts | @Public() → metadata is_public |
src/common/permission/permission.key.ts | HTTP verb → suffix map, key builder, general fallback resource |
src/common/permission/permission.registry.ts | Boot scan of every controller via DiscoveryService; route → key map; key list for GET /permissions |
src/common/permission/permission.controller.ts | GET /permissions |
src/common/permission/permission.types.ts | CLI npm run permissions:types -- <out.ts> [--routes]: writes the PermissionKey union from built controllers |
src/common/permission/permission.module.ts | Registry provider + controller |
src/common/scope/scope.ts | @Scoped(map), scopeFilter, withScope, assignScope, asSystem |
src/common/base/base.service.ts | Applies withScope/assignScope to every read/write path of a @Scoped service |
src/common/context/request_context.ts | AsyncLocalStorage store: actor, channel, access, store_id, system |
src/common/context/context.middleware.ts | Fastify onRequest hook opens an http context per request |
src/common/context/actor.ts | Actor from session (legacy user.role), system actor |
src/common/enums/user.enum.ts | UserState, legacy UserRole, RoleCode, UserType, ALL_STORES='ALL' |
src/auth/auth.controller.ts | /auth/access_token, /auth/revoke, /auth/verify/otp (public), /auth/me (logged in) |
src/auth/auth.service.ts | Token issue/revoke, OTP verify, /auth/me store resolution |
src/auth/auth.me.ts | MeOutput shape, scopedStoreIds, buildMe |
src/auth/auth.settings.ts | authen.enable_universal_otp, authen.universal_otp settings |
src/role/role.schema.ts | roles collection |
src/role/role.dto.ts | CreateRoleInput, UpdateRoleInput |
src/role/role.controller.ts | /roles CRUD |
src/role/role.service.ts | Seed, user backfill, role CRUD with version check, audit, cache invalidation |
src/role/role.seed.ts | Seed role list and keys; userTypeOf(role, system) |
src/role/role.access.ts | RoleAccessService.forUser/forApplication → CallerAccess |
src/role/role.cache.ts | Redis cache of roles and user access (Lua lookup, invalidate, flush) |
src/role/role.constants.ts | Cache TTL/timeout/re-delete env knobs |
src/role/role.module.ts | Imports UserModule, PermissionModule, ActivityLogModule |
src/user/user.schema.ts | users collection (+ role_ids, user_type, scopes) |
src/user/user.service.ts | System user seed, system_admin login seed, login check, cache-invalidating update, accessOf, countByRoles, holdsRole, createUser |
src/user/user.admin.controller.ts | /users admin API |
src/user/user.admin.dto.ts | UpdateUserAccessInput with IsStoreScope validator |
src/user/user.admin.service.ts | Access/unlock/enable/disable in a transaction, last-system-admin guard, audit |
src/user/user.controller.ts | Legacy /user routes (search, by user_id, me, create) |
src/application/application.schema.ts | applications collection (+ role_ids) |
src/application/application.service.ts | Default app seed, app role_ids backfill, app CRUD |
src/store/store.service.ts | @Scoped({ store: '_id' }); unscoped exists(id) for the guard |
src/delivery_order/delivery_order.service.ts, src/delivery_order_item/delivery_order_item.service.ts, src/plan/plan.service.ts, plan_operation.service.ts, plan_optimize.service.ts, src/plan_history/plan_history.service.ts, src/script_runner/script_runner.service.ts, src/attachment/attachment.sweep.processor.ts | Consumers of the scope helpers (see §5) |
src/common/utils/redis.ts | redisSession / redisCache clients; eval, onReady added |
Web
| File | Responsibility |
|---|---|
web:src/pages/_app.tsx | SSR loads /auth/me with X-Store-Id from cookie; drops a stale store cookie; renders ForbiddenPage when Component.permission is missing |
web:src/stores/profile/index.ts, types.ts | zustand profile: profile, all, permissions:Set, selectedStore, can() |
web:src/hooks/permission/index.ts | usePermission() → { can, hasNoKey } |
web:src/components/Can/index.tsx | <Can permission> hides children |
web:src/utils/permission.ts | profileCan (SSR), withPermission(page, key), groupPermissions |
web:src/constants/permission-keys.ts | Hand-written PermissionKey union (diverges from core, see §12) |
web:src/constants/menu.ts, web:src/utils/menu.ts, web:src/hooks/menu/index.ts | Menu with permission per entry; prepareMenu drops forbidden entries and empty groups |
web:src/utils/store_scope.ts | Cookie names, resolveSelectedStore, defaultStoreCode, writeStoreCookies |
web:src/hooks/store_scope/index.ts | useDefaultStoreCode() for create forms |
web:src/apis/server/request.ts | getHeadersFromRequest forwards X-Store-Id; clearStoreCookies |
web:src/apis/server/user/index.ts | requestMyProfile (/auth/me, 5 s timeout), requestMyProfileResolved (retry without a rejected store) |
web:src/apis/{server,client}/role/*, web:src/apis/{server,client}/users/* | Roles / permissions / users API wrappers |
web:src/pages/api/permissions.ts, roles/index.ts, roles/[id].ts, users/index.ts, users/[id]/[action].ts, user/me.ts, auth/login.ts, auth/revoke.ts | Next API proxy routes |
web:src/apis/client/request.ts | 403 permission_denied → toast (no logout) |
web:src/pages/setting/roles/index.tsx, web:src/services/role/index.ts | Roles page + state hook |
web:src/pages/setting/users/index.tsx, [id].tsx, web:src/services/users/index.ts | Users list / detail + hooks |
web:src/components/StorePicker/index.tsx | Top-bar store picker |
web:src/components/UserBadge/index.tsx | Name, roles, logout |
web:src/components/business/store/StoreSelect/index.tsx | One-line multi-store field (Users detail) |
web:src/components/business/role/Item/index.tsx, web:src/components/ui/CounterPill/index.tsx | Role list row, n/total pill |
web:src/components/layouts/Main/Header/index.tsx | Mounts StorePicker + UserBadge |
web:src/pages/403.tsx, 404.tsx, 500.tsx, _error.tsx, index.tsx, web:src/components/ui/ErrorPage/* | Shared error layout; home shows "no access" when the caller holds no key |
web:src/drafts/auth/fixtures.ts, web:src/pages/drafts/error.tsx | Design draft fixtures (not wired to real data) |
3. Data model
roles (src/role/role.schema.ts, strict, timestamps created_at/updated_at)
| Field | Type | Req | Default | Validation | Index | Note |
|---|---|---|---|---|---|---|
code | string | yes | — | create DTO ^[a-z][a-z0-9_]*$ (src/role/role.dto.ts:5); system_admin refused on create | unique (:29) | immutable (no update path) |
name | string | yes | — | IsNotEmpty | — | |
permissions | string[] | — | [] | each must be a registry key, except keys already on the role (src/role/role.service.ts:188-191) | — | system_admin keeps [] and passes by code |
is_system | boolean | — | false | — | — | true for the 5 seeded roles |
version | number | — | 1 | update DTO IsInt Min(1) | — | optimistic lock, $inc on every save |
holder_rev | number | — | absent | — | — | not in schema; bumped by _keepOneAdmin on the system_admin row only, as a write-conflict lock (src/user/user.admin.service.ts:112) |
Seeded rows (src/role/role.seed.ts:12-32):
| code | name | permissions at seed |
|---|---|---|
system_admin | System Admin | [] (passes all by code) |
admin | Admin | every registry key (55 today) |
store_operator | Store Operator | delivery_order.read, plan.read, store.read |
transport_operator | Transport Operator | [] |
driver | Driver | [] |
users (src/user/user.schema.ts)
| Field | Type | Req | Default | Enum / validation | Index | Note |
|---|---|---|---|---|---|---|
user_id | string | yes | uuid on create | — | unique | external id |
username | string | yes | — | lower-cased at login | unique (:53) | |
first_name, last_name | string | — | — | — | — | |
role | string | — | — | legacy UserRole | — | kept, still drives the actor (src/common/context/actor.ts:36) |
state | string | — | active with password, else staged (src/user/user.service.ts:199-201) | staged/active/inactive/locked_out/suspended | — | only active may log in |
password | string | — | — | sha256(password:created_at) via generateHash | — | |
system | boolean | — | — | — | — | true only for the boot system user |
role_ids | ObjectId[] ref role | — | undefined (absent) | admin API: each IsMongoId, must exist | no index | absent = "not backfilled yet" |
user_type | string | — | — | system/internal/staff/driver | — | set by backfill / createUser |
scopes | Mixed { store?: 'ALL' | string[] } | — | — | admin API IsStoreScope (src/user/user.admin.dto.ts:8-14) | — | store ids saved as strings |
last_logged_in_at | Date | — | — | — | — | set on login |
applications (src/application/application.schema.ts)
| Field | Type | Req | Note |
|---|---|---|---|
name | string | yes | unique |
code | string | yes | unique (:46); default = backend-to-backend key |
app_key | string | — | unique (:45); default app takes DEFAULT_APP_KEY |
secret_key | string | — | plain text, masked in responses (@MaskFields) |
enabled | boolean | — | not checked at login or Basic auth |
roles | string[] | — | legacy, ignored by the guard |
role_ids | ObjectId[] ref role | — | the API key's own roles; default app [system_admin], others [] |
enable_2fa, require_device | boolean | — | login flow switches |
Scoped-entity fields added by this part
| Collection | Field | Index | Filled by |
|---|---|---|---|
delivery_orders | source_store_id (string, ref store) | no index (src/delivery_order/delivery_order.schema.ts:26-27) | _resolveSourceStore from source_store.code (src/delivery_order/delivery_order.service.ts:92) |
delivery_order_items | store_id (string, ref store) | yes | copied from the DO at save (src/delivery_order_item/delivery_order_item.service.ts:64) |
plans | store_id (string, ref store) | yes | createPlan (src/plan/plan.service.ts:60); boot backfill from store.code (:32-43) |
stores | _id | — | the scope is its own id |
Scope map listing (jest snapshot src/common/scope/__snapshots__/scope.spec.ts.snap): scoped = delivery_order{store:source_store_id}, delivery_order_item{store:store_id}, plan{store:store_id}, store{store:_id}. Everything else is unscoped, including user, application, attachment, plan_history (filtered indirectly), device, vehicle.
Redis keys
| Client | Key | Value | TTL | Written | Deleted |
|---|---|---|---|---|---|
redisSession (SESSION_REDIS_URL) | TOKEN_<token> (src/common/utils/key.ts:1) | { user, application, device } (login snapshot, full user doc) | ACCESS_TOKEN_TTL = 1 year (src/common/constants/auth.constant.ts:2) | login / OTP verify | /auth/revoke (not awaited, src/auth/auth.service.ts:172) |
redisSession | OTP_<ref> | OTP payload | from utils/otp | 2FA login | OTP verify |
redisCache (CACHE_REDIS_URL) | role:<roleId> (src/role/role.cache.ts:9) | { id, code, name, permissions } | ACCESS_CACHE_TTL (3600 s) | cache miss in forUser | role update/delete after commit + re-delete after 1 s; role:* flush on every Redis ready |
redisCache | user_access:<userId> (:10) | { role_ids[], scopes, user_type } | ACCESS_CACHE_TTL | cache miss in forUser | user admin change after commit, UserService.update outside a tx, user_access:* flush on ready |
Applications (Basic auth) are never cached: forApplication reads Mongo every request (src/role/role.access.ts:86-88, plus the applications lookup in src/common/guards/auth.guard.ts:63).
Relations
4. API
Error body shape for every throwError: { statusCode, code, message: string[], additional_info, error: '', ...extra } (src/common/utils/error.ts:4-18). Guard errors: no/bad token → 401 unauthorized, missing key → 403 permission_denied with additional_info.key (src/common/guards/auth.guard.ts:105,112), bad X-Store-Id → 403 store_forbidden (:30).
Auth
| Method + route | Key | Request | Response | Errors | Code |
|---|---|---|---|---|---|
POST /api/v1/auth/access_token | public | body { app_key, secret_key, username, password, device? } | { token, expires_in } or { need_2fa: true, otp_ref } | 400 invalid_application (unknown key, wrong secret, device missing when require_device); 400 invalid_username_or_password (unknown, not active, wrong password) | src/auth/auth.controller.ts:28-34, src/auth/auth.service.ts:55-134, src/user/user.service.ts:131-169 |
POST /api/v1/auth/verify/otp | public | { otp } | { token, expires_in } | from validateOtp | src/auth/auth.service.ts:136-165 |
POST /api/v1/auth/revoke | public | { token } | { status: true } always | none | src/auth/auth.service.ts:167-176 |
GET /api/v1/auth/me | logged in | headers Authorization, optional X-Store-Id | { user:{id,user_id,username,first_name,last_name,user_type}, roles:[{code,name}], all, permissions: string[] ([] when all), stores: 'ALL' | [{id,code,name}] sorted by code } | 401; 403 store_forbidden for an unheld X-Store-Id (the guard runs first) | src/auth/auth.controller.ts:52-56, src/auth/auth.service.ts:179-187, src/auth/auth.me.ts:20-40 |
stores lists every store the user holds, and the X-Store-Id narrowing never applies to it because the store read runs inside asSystem (src/auth/auth.service.ts:183-184). A user listing ids that no longer exist gets only the existing ones.
Permissions and roles
| Method + route | Key | Request | Response | Errors | Code |
|---|---|---|---|---|---|
GET /api/v1/permissions | role.read | — | { items: [{ key, resource, suffix, label }] } sorted by key | 401/403 | src/common/permission/permission.controller.ts:13-16 |
GET /api/v1/roles | role.read | — | { items: [{ id, code, name, permissions, is_system, version, user_count }] } sorted by code | — | src/role/role.service.ts:92-96 |
GET /api/v1/roles/:id | role.read | — | one item | 404 not_found | :98-102 |
POST /api/v1/roles | role.create | { code, name, permissions[] } | item, user_count: 0, version 1, is_system false | 403 system_role_locked (code system_admin); 400 unknown_permission + keys; 409 duplicate_code; 400 validation | :104-125 |
PUT /api/v1/roles/:id | role.update | { name?, permissions[] (whole array), version } | item after save | 404; 403 system_role_locked; 409 rev_conflict + version (current); 400 unknown_permission | :128-159 |
DELETE /api/v1/roles/:id | role.delete | — | deleted item, user_count 0 | 404; 403 system_role_locked (any is_system); 409 role_in_use; 409 rev_conflict | :161-180 |
Users admin
| Method + route | Key | Request | Response | Errors | Code |
|---|---|---|---|---|---|
GET /api/v1/users | user.read (@Permission('read')) | — | { items: [{ id, user_id, username, first_name, last_name, user_type, state, role_ids[], scopes }] }, system user excluded, sorted by username | — | src/user/user.admin.service.ts:30-33 |
PUT /api/v1/users/:id/access | user.update | { role_ids: mongoId[], scopes: { store: 'ALL' | storeId[] } } | user item | 403 self_change_forbidden; 404 not_found (incl. the system user); 400 store_required (non-system/internal with empty list); 400 unknown_role; 400 unknown_store; 409 last_system_admin; 400 DTO | :35-49 |
POST /api/v1/users/:id/unlock | user.update | — | user item | 404 | :52-54 |
POST /api/v1/users/:id/enable | user.update | — | user item | 403 self; 409 invalid_state (staged); 404 | :56-62 |
POST /api/v1/users/:id/disable | user.update | — | user item | 403 self; 409 last_system_admin; 404 | :64-71 |
Legacy user routes (touched by this part)
| Method + route | Key | Note |
|---|---|---|
GET /api/v1/user, GET /api/v1/user/:user_id, GET /api/v1/user/me | user.read | /user/me needs user.read, not just a login (src/user/user.controller.ts:44-49) |
POST /api/v1/user | user.create | createUser gives role_ids=[role] and user_type when the legacy role exists. It sets no scopes (src/user/user.service.ts:208-212) |
PUT /api/v1/user/me | logged in | first/last name only |
POST /api/v1/device/heartbeat | logged in | src/device/device.controller.ts:33-34 |
Permission key registry (as built, 55 keys from 90 routes)
Produced from the built controllers with node dist/common/permission/permission.types <out> --routes at 9d9422c. All routes are under /api/v1.
| Key | Resource | Route(s) |
|---|---|---|
activity_log.read | activity_log | GET /activity_logs, GET /activity_logs/:entity/:entity_id |
delivery_order.create | delivery_order | POST /delivery_order |
delivery_order.read | delivery_order | GET /delivery_order, GET /delivery_order/:delivery_no |
delivery_order.update | delivery_order | PUT /delivery_order/:delivery_no |
delivery_order_item.create | delivery_order_item | POST /delivery_order_item |
delivery_order_item.delete | delivery_order_item | DELETE /delivery_order_item/:item_id |
delivery_order_item.read | delivery_order_item | GET /delivery_order_item, GET /delivery_order_item/:item_id, GET /delivery_order_item/order/:delivery_no |
delivery_order_item.update | delivery_order_item | PUT /delivery_order_item/:item_id |
device.read | device | GET /device |
plan.create | plan | POST /plan, POST /plan/:code/re_plan, POST /plan/:code/move_stop, POST /plan/:code/assign_stop, POST /plan/:code/unassign_stop, POST /plan/:code/generate_task |
plan.read | plan | GET /plan, GET /plan/:code |
plan_history.read | plan_history | GET /plan_history, GET /plan_history/plan/:code |
plan_template.create | plan_template | POST /template/plan |
plan_template.read | plan_template | GET /template/plan, GET /template/plan/:code |
plan_template.update | plan_template | PUT /template/plan/:code |
role.create | role | POST /roles |
role.delete | role | DELETE /roles/:id |
role.read | role | GET /roles, GET /roles/:id, GET /permissions |
role.update | role | PUT /roles/:id |
setting.application.create | setting.application | POST /application |
setting.application.delete | setting.application | DELETE /application/:code |
setting.application.read | setting.application | GET /application, GET /application/:code |
setting.application.update | setting.application | PUT /application/:code, PATCH /application/revoke |
setting.attachment.create | setting.attachment | POST /attachments, POST /attachments/:id/confirm |
setting.attachment.read | setting.attachment | GET /attachments, GET /attachments/:id, GET /attachments/:id/content |
setting.export_config.create | setting.export_config | POST /export_configs |
setting.export_config.read | setting.export_config | GET /export_configs, GET /export_configs/:id |
setting.export_config.update | setting.export_config | PUT /export_configs/:id |
setting.field_catalog.read | setting.field_catalog | GET /export_fields, GET /export_fields/:entity |
setting.general.create | setting.general | POST /settings/:code/reset |
setting.general.read | setting.general | GET /settings, GET /settings/:code |
setting.general.update | setting.general | PUT /settings/:code |
setting.jobs.create | setting.jobs | POST /jobs/:id/retry, POST /jobs/:id/skip |
setting.jobs.read | setting.jobs | GET /jobs |
setting.kafka.create | setting.kafka | POST /kafka/test-connection |
setting.kafka.read | setting.kafka | GET /kafka/status |
setting.schedules.create | setting.schedules | POST /schedules/:code/enable, POST /schedules/:code/disable |
setting.schedules.delete | setting.schedules | DELETE /schedules/:code/cron_override |
setting.schedules.read | setting.schedules | GET /schedules |
setting.schedules.update | setting.schedules | PUT /schedules/:code/cron_override |
setting.template.create | setting.template | POST /templates, POST /templates/render |
setting.template.read | setting.template | GET /templates, GET /templates/:id |
setting.template.update | setting.template | PUT /templates/:id |
store.create | store | POST /store |
store.read | store | GET /store, GET /store/:code |
store.update | store | PUT /store/:code |
user.create | user | POST /user |
user.read | user | GET /user, GET /user/:user_id, GET /user/me, GET /users |
user.update | user | PUT /users/:id/access, POST /users/:id/unlock, POST /users/:id/enable, POST /users/:id/disable |
vehicle.create | vehicle | POST /vehicle |
vehicle.read | vehicle | GET /vehicle, GET /vehicle/:code |
vehicle.update | vehicle | PUT /vehicle/:code |
vehicle_type.create | vehicle_type | POST /type/vehicle |
vehicle_type.read | vehicle_type | GET /type/vehicle, GET /type/vehicle/:code |
vehicle_type.update | vehicle_type | PUT /type/vehicle/:code |
Routes without a key: public = POST /auth/access_token, POST /auth/revoke, POST /auth/verify/otp, GET /settings/public (controller @Resource('setting.google'), the only route), GET /health_check. Logged in only = GET /auth/me, POST /device/heartbeat, PUT /user/me. No general.* key exists today, because every controller carries @Resource. The only route-level @Permission uses are the 5 /users routes, and they repeat the verb default.
5. Conditions
| When … | The system must … | Enforced at | Test |
|---|---|---|---|
a route has @Public() (handler or class) | let it through even with a missing/expired/bad token, but still attach the session when the token is valid | src/common/guards/auth.guard.ts:100-104 | src/common/guards/auth.guard.spec.ts:172 (C5) |
| a guarded route gets no token, or a token not in Redis, or a bad Basic pair | 401 unauthorized | auth.guard.ts:78-86,105 | auth.guard.spec.ts:179 |
the Authorization header starts with Basic | treat base64(app_key:secret_key) as an API key; access = the application's role_ids | auth.guard.ts:60-75,88-93 | auth.guard.spec.ts:191 (P4) |
a route has @LoggedIn() | pass any valid token without a key | permission.registry.ts:65,72, auth.guard.ts:111 | auth.guard.spec.ts:185, src/auth/auth.me.spec.ts:47 |
the caller holds a role with code system_admin | pass every key | src/role/role.access.ts:35, auth.guard.ts:111 | auth.guard.spec.ts:152 |
| the route key is in the union of the caller's role keys | pass; otherwise 403 permission_denied with key | auth.guard.ts:111-112, role.access.ts:36 | auth.guard.spec.ts:138,146,158; src/role/role.access.spec.ts:63 |
| a role id on the caller no longer exists | it contributes no keys, no error | role.access.ts:90-96 | role.access.spec.ts:46 |
a route has no @Permission | suffix = GET read, POST create (any POST path), PUT/PATCH update, DELETE delete, else the lower-case verb | src/common/permission/permission.key.ts:5-17 | auth.guard.spec.ts:64-98 |
a controller has no @Resource | prefix general | permission.key.ts:3,19-20, permission.registry.ts:69 | auth.guard.spec.ts:72,76,208 |
@Resource is put on a handler | ignore it, keep the class resource, log a warning | permission.registry.ts:66-69 | auth.guard.spec.ts:90 (C2) |
| two routes share a key | list the key once; a later @Permission label replaces a fallback label | permission.registry.ts:75-80 | auth.guard.spec.ts:110 |
| a handler was not seen at boot | register it lazily on first request | permission.registry.ts:58-61 | indirect (guard spec uses test controllers) |
X-Store-Id is present | it must be 24-hex; the lower-cased id is used | auth.guard.ts:14,26-32 | src/common/scope/scope.spec.ts:252 |
a user session sends a store not in its scopes.store list (not ALL, not system_admin) | 403 store_forbidden, even if the store exists | auth.guard.ts:33-37 | auth.guard.spec.ts:261 (C13, real login path), scope.spec.ts:264 |
| an ALL holder, system_admin or API key sends any store | accept it only if the store exists; unknown → 403 | auth.guard.ts:38, src/store/store.service.ts:24-26 | scope.spec.ts:258,264 |
a service class carries @Scoped(map) | AND the scope filter outside the client filter on search/list/count/find/aggregate/updateMany/deleteMany | src/common/base/base.service.ts:67, :173-188, src/common/scope/scope.ts:36-52 | scope.spec.ts:72,83,87,156,163 |
| a by-id get/update/delete targets an out-of-scope doc | 404 not_found, doc unchanged | base.service.ts:106-113,177,228-230,244-247 | scope.spec.ts:93 |
a selected store is set (ctx.store_id) | narrow the store scope to that one id for everyone, including system_admin and API keys | scope.ts:28 | scope.spec.ts:124,128,134 |
the context is not http, is system, has access.all, or comes from an API key | no scope restriction (apart from a selected store) | scope.ts:23-24 | scope.spec.ts:189,198 |
an http caller has no scopes.<name> value (or a public route touches a scoped entity) | match nothing ($in: []) | scope.ts:30-32 | scope.spec.ts:178,185 |
| a create on a scoped entity has no scope value | fill it from the single allowed id (selected store or the only store); several or ALL → 400 store_required | scope.ts:83-91 | scope.spec.ts:139,146,151 |
| a create/update names a scope value the caller may not use | 403 store_forbidden | scope.ts:84-86 | scope.spec.ts:102,139 |
an update body carries $set/$setOnInsert on the scope field | check the value like a plain field; null/'' → 403 | scope.ts:59-71 | scope.spec.ts:106 |
an update body carries any other operator ($unset, $rename to/from, $inc …) on the scope field, or a non-object operator value | 403 | scope.ts:62-66 | scope.spec.ts:106,116 |
aggregate runs on a scoped service | prepend a $match cast through the schema, so string ids match ObjectId fields | base.service.ts:171-177 | scope.spec.ts:168 (C8) |
| a DO is updated by delivery no | apply assignScope(..., false) and a scoped findOne inside the DO transaction; missing → 404 | src/delivery_order/delivery_order.service.ts:140-149 | src/common/scope/scope.bypass.spec.ts:89 |
| a DO item is created | copy store_id from its DO | delivery_order_item.service.ts:64 | scope.spec.ts:220 (C5) |
| plan history is read | return rows only for plan codes visible under the plan scope | src/plan_history/plan_history.service.ts:31-35,51-62 | scope.bypass.spec.ts:63 |
| plan optimize saves a plan | scope the findOneAndUpdate | src/plan/plan_optimize.service.ts:913 | scope.bypass.spec.ts:72,78,84 |
| the script sandbox calls a reader back | run it in the caller's async context (AsyncResource.bind) | src/script_runner/script_runner.service.ts:132-135 | scope.bypass.spec.ts:95 |
| the attachment sweep runs | run as system actor | src/attachment/attachment.sweep.processor.ts:45-46 | scope.bypass.spec.ts:108 |
a role is created with code system_admin, or the system_admin row is updated/deleted | 403 system_role_locked (even for system_admin) | role.service.ts:105,130,163 | src/role/role.crud.spec.ts:139 |
| a role save carries a key not in the registry and not already on the role | 400 unknown_permission with keys | role.service.ts:133,188-191 | role.crud.spec.ts:78,116 |
a role save sends a stale version | 409 rev_conflict with the current version; nothing written | role.service.ts:131,139-148 | role.crud.spec.ts:85,123 |
| a role is saved | write activity_logs {entity:'role', action:create/update/delete, changes.permissions{added,removed}} in the same transaction | role.service.ts:116-121,149-155,170-176 | role.crud.spec.ts:95,102 |
| an admin grants a key they do not hold | allow it | no check exists | role.crud.spec.ts:110 |
a seeded role (is_system) is deleted | 403 system_role_locked | role.service.ts:163 | role.crud.spec.ts:146 |
| a custom role is still on any user | 409 role_in_use (applications are not checked) | role.service.ts:165-167, src/user/user.service.ts:127-129 | role.crud.spec.ts:146 |
| an admin changes their own access, or enables/disables themselves | 403 self_change_forbidden (unlock of self is allowed) | src/user/user.admin.service.ts:36,57,65,143-145 | src/user/user.admin.service.spec.ts:88 |
a user that is not system/internal is saved with an empty store list | 400 store_required (drivers too) | user.admin.service.ts:40-42 | user.admin.service.spec.ts:95 |
| access names an unknown role or store id | 400 unknown_role / unknown_store | user.admin.service.ts:122-132 | user.admin.service.spec.ts:109 |
| system_admin is removed from (or disable hits) an active holder and no other active non-system holder remains | 409 last_system_admin | user.admin.service.ts:45-46,68,111-120 | user.admin.service.spec.ts:118,139,150,157 |
a staged user is enabled | 409 invalid_state | user.admin.service.ts:59 | untested |
| unlock/enable/disable would not change the state | write nothing but still log the action | user.admin.service.ts:53,60,67,84-93 | user.admin.service.spec.ts:181 |
| any user admin change commits | drop user_access:<id>, then re-drop it after 1 s | user.admin.service.ts:94, role.cache.ts:90-97 | user.admin.service.spec.ts:65 |
a user doc is updated through UserService.update without a session | drop its access cache | src/user/user.service.ts:100-104 | indirect |
| a user logs in | no app or role check; only state === active and the password matter | src/auth/auth.service.ts:94-97, src/user/user.service.ts:146-164 | src/auth/auth.service.spec.ts:18-34 |
/auth/me is answered | stores = every held store, ignoring X-Store-Id; permissions=[] when all | auth.service.ts:179-187, auth.me.ts:20-40 | auth.me.spec.ts:16-85 |
a user is created via POST /user with an existing legacy role | role_ids=[that role], user_type from userTypeOf | user.service.ts:208-212 | src/user/user.service.spec.ts:79 |
6. States and flows
User state (as built)
| From | Action | To | Guard |
|---|---|---|---|
| (new, no password) | createUser | staged | — |
| (new, password) | createUser | active | — |
locked_out | unlock | active | — |
| any other | unlock | unchanged (logged) | — |
inactive / locked_out / suspended | enable | active | not self |
staged | enable | — | 409 invalid_state |
active / locked_out / suspended / staged | disable | inactive | not self; last system_admin check only when active |
Nothing in code sets locked_out or suspended today (lockout is next round).
Request through the guard
Login (no 2FA)
Access resolution (forUser)
Role save
Boot seed and backfill order
Seed rules (src/role/role.service.ts:37-66): $setOnInsert creates missing roles. An admin still at is_system: true, version: 1 gets $addToSet of every registry key on every boot. Any other unedited seeded role with permissions: [] gets its seed keys. Edited roles (version > 1) are never touched. User backfill (:74-90) only touches users without role_ids and maps legacy role → role id. user_type comes from userTypeOf (src/role/role.seed.ts:34-39): system user → system, system_admin → internal, driver → driver, else staff. scopes.store = 'ALL' goes to staff and internal; drivers get no scopes. App backfill (src/application/application.service.ts:74-84): default → [system_admin], others without role_ids → [], and a non-default app holding exactly [system_admin] is reset to [] on every boot.
Web SSR profile
7. Jobs, schedules, queues, events
| Name | Trigger | Payload | Retry | Idempotency |
|---|---|---|---|---|
Role seed (RoleService.onApplicationBootstrap) | every boot | registry keys | none; duplicate-key from a parallel pod swallowed | upserts by code, filters on version: 1 |
| User backfill | every boot after seed | — | none | only role_ids: {$exists:false} |
App backfill (ApplicationService.onApplicationBootstrap) | every boot | system_admin role id | none | filters; the [system_admin] reset re-applies every boot |
System user ensure (UserService.onModuleInit) | every boot | — | duplicate-key → re-read | upsert on {username:'system', system:true} |
system_admin login user (_initialSystemUser) | every boot, not awaited | DEFAULT_SYSTEM_ADMIN_PASSWORD | none | find-then-create (no duplicate guard beyond the unique index) |
Plan store_id backfill (PlanModule.onModuleInit) | every boot, not awaited | — | errors logged | store_id: {$exists:false} |
| Access cache flush | every Redis ready (first connect and each reconnect) per pod | role:*, user_access:* | errors logged | SCAN + DEL |
| Attachment sweep | existing Bull job | — | existing | now wrapped in asSystem |
No Kafka events are produced by AUTH. Kafka, job and schedule handlers see every store because their context channel is not http (src/common/scope/scope.ts:23-24).
8. Settings and env
| Key | Default | Effect | Read at |
|---|---|---|---|
SESSION_REDIS_URL | required | token store | src/common/utils/redis.ts:177 |
CACHE_REDIS_URL | required | role / user-access cache | redis.ts:178 |
ACCESS_CACHE_TTL | 3600 s | cache entry lifetime | src/role/role.constants.ts:2 |
ACCESS_CACHE_TIMEOUT_MS | 150 | cache call slower than this → DB | role.constants.ts:5 |
ACCESS_CACHE_REDELETE_MS | 1000 | delay of the second invalidation | role.constants.ts:8 |
DEFAULT_APP_KEY / DEFAULT_SECRET_KEY | random | default application credentials on first boot | src/application/application.service.ts:61-62 |
DEFAULT_SYSTEM_ADMIN_PASSWORD | — | password of the system_admin login user on first boot | src/user/user.service.ts:84 |
ACCESS_TOKEN_TTL (const) | 1 year | token TTL and expires_in | src/common/constants/auth.constant.ts:2 |
setting authen.enable_universal_otp | false | allow universal OTP | src/auth/auth.settings.ts:10, auth.service.ts:52 |
setting authen.universal_otp (encrypted JSON list) | null | universal OTP codes | auth.settings.ts:12, auth.service.ts:45-53 |
web COOKIE_SAME_SITE, COOKIE_SECURE, COOKIE_HTTP_ONLY | lax / false / false | token cookie flags | web:src/pages/api/auth/login.ts:13-18 |
web cookies token, store_id, store_info | — | session, selected store id, picker hint {code,name} | web:src/utils/store_scope.ts:5-6 |
9. Concurrency and recovery
| Situation | What the code does | Where |
|---|---|---|
| Two pods boot on an empty db | role upserts race; a duplicate-key error is swallowed and the unique code index keeps one row. The system user loser re-reads the winner's row. A default app duplicate is swallowed | src/role/role.service.ts:59-65, src/user/user.service.ts:54-58, src/application/application.service.ts:66-69; tests src/role/role.service.spec.ts:56,134 |
| Two admins save the same role version | updateOne({_id, version}) inside a tx; the loser gets 409 with the current version | role.service.ts:139-148; src/role/role.crud.spec.ts:123 (20 runs) |
| Role edited on pod A, request on pod B | cache is shared Redis only (no in-pod memory); role:<id> deleted after commit, so B reads the new keys on its next request | src/role/role.cache.ts:90-95 |
| Reader refills the cache from pre-commit state during a save | second delete after ACCESS_CACHE_REDELETE_MS | role.cache.ts:92 |
| Redis cache down or slow | every lookup races a 150 ms timeout, then reads Mongo. Writes and deletes fail quietly. On reconnect each pod flushes role:* and user_access:*, so a delete missed during the outage cannot leave a stale entry | role.cache.ts:47-57,76-82,100-107, src/role/role.access.ts:51-54,61-67; src/role/role.access.spec.ts:38 |
| Session Redis down | redisSession.get throws → the guard throws (500 on guarded routes); public routes swallow the error | src/common/guards/auth.guard.ts:43,102 |
| Two admins remove system_admin from the last two holders in parallel | each tx bumps holder_rev on the system_admin role row, so the transactions write-conflict; withTransaction retries one, which then sees the other's change and gets 409 | src/user/user.admin.service.ts:110-120; src/user/user.admin.service.spec.ts:157 |
| Process dies after a commit, before afterCommit | the cache entry stays stale until TTL (≤ 1 h) or the next Redis ready flush | src/common/utils/transaction.ts:24-30 |
| User disabled while holding live tokens | tokens keep working until their TTL (1 year); only new logins fail (D-578 accepted this) | src/auth/auth.service.ts:124-128 |
| Role change reaches a logged-in user | next request, no re-login: access comes from the user record and cache, never the login snapshot | role.access.ts:56-79 |
| Same request processed twice | role/user writes are not idempotent by request id; a retried role PUT with the old version gets 409 | — |
Boot-time _initialSystemUser vs user backfill | not awaited, so on a fresh db the system_admin login user may be created after backfillUsers and stay without role_ids until the next boot | src/user/user.service.ts:35-37 |
10. Web (store-v2)
Permission plumbing
| Piece | Behaviour | Where |
|---|---|---|
| Profile load | SSR only (getInitialProps on the server). On client navigations the zustand store keeps the SSR profile | web:src/pages/_app.tsx:122-177 |
| Fail closed | 404/5xx/timeout of /auth/me → profile with no keys; 401/403 → token cleared (login redirect) | _app.tsx:139-149 |
| Page gate | withPermission(Page, key) sets Page.permission. SSR sets status 403. Render shows ForbiddenPage | web:src/utils/permission.ts:9-10, _app.tsx:105-109,163-165 |
| Button gate | <Can permission> / usePermission().can (all → true) | web:src/components/Can/index.tsx, web:src/hooks/permission/index.ts |
| Menu gate | entry hidden without its key; a group with no children left is dropped; order kept | web:src/utils/menu.ts:5-20, web:src/constants/menu.ts |
| No key at all | home page shows "no access" in the ErrorPage layout; menu is empty | web:src/pages/index.tsx |
| API 403 | permission_denied → toast, no logout; legacy "Forbidden resource" body → login redirect | web:src/apis/client/request.ts:39-44 |
| Error pages | 403/404/500/_error share ErrorPage with an illustration and a home link | web:src/components/ui/ErrorPage/index.tsx |
Page → key map: /plan plan.read · /delivery/delivery-order delivery_order.read · /master_data/driver user.read · /master_data/vehicle vehicle.read · /master_data/store store.read · /master_data/vehicle_type vehicle_type.read · /master_data/plan_template plan_template.read · /setting/application setting.application.read · /setting/kafka setting.kafka.read · /setting/roles role.read · /setting/users, /setting/users/[id] user.read · /setting/authen setting.authen.read* · /setting/google setting.google.read* · /setting/storage setting.storage.read* · /setting/attachments setting.attachments.read* · /setting/outbound-events setting.outbound_events.read* · /setting/templates setting.templates.read* · /setting/script_settings setting.script_settings.read* · /setting/export_configs setting.export_configs.read*. * = key not in the core registry (§12).
Store scope in the web
| Piece | Behaviour | Where |
|---|---|---|
| Header forward | every server proxy call adds X-Store-Id from cookie store_id when it is 24-hex | web:src/apis/server/request.ts:54-81 |
| Stale cookie | a cookie not in /auth/me stores, malformed, or rejected with store_forbidden → cookies cleared, picker on default | web:src/utils/store_scope.ts:27-43, web:src/apis/server/user/index.ts:12-20, _app.tsx:153-162 |
| Login / logout | login clears store cookies. Logout clears token + store cookies even when revoke fails, then the badge goes to /login | web:src/pages/api/auth/login.ts:12, web:src/pages/api/auth/revoke.ts:8-27, web:src/components/UserBadge/index.tsx:23-28 |
| StorePicker | one store → name only. Several → "All my stores" + list. ALL → search GET /store (page size 50, 300 ms debounce). Pick → write cookies + full reload | web:src/components/StorePicker/index.tsx |
| Create-form default | top-bar store, else first profile store; DO form sets source_store.code, plan form sets store.code | web:src/utils/store_scope.ts:46-52, web:src/pages/delivery/delivery-order/index.tsx, web:src/services/plan_request/index.ts:27-39 |
Roles page (/setting/roles)
| Element | Behaviour | Where |
|---|---|---|
| List | CompactList of GET /roles: name (code in tooltip), lock icon for system roles, user count | web:src/components/business/role/Item/index.tsx |
| Detail | code (editable only on create), name (locked for system_admin). Keys come from GET /permissions, grouped by the prefix before the last ., one full-width row per group with a group checkbox and a CounterPill. An unknown prefix shows raw | web:src/pages/setting/roles/index.tsx:16-151, web:src/utils/permission.ts:13-21 |
| system_admin | "All permissions" locked panel, no Save | roles/index.tsx:43,84,114-121 |
| Save | create {code,name,permissions} (role.create); update {name,permissions,version} (role.update); 409 rev_conflict → banner + reload | web:src/services/role/index.ts:62-86 |
| Delete | only non-system, role.delete, disabled while user_count > 0, confirm modal | roles/index.tsx:77-83 |
Users pages
| Element | Behaviour | Where |
|---|---|---|
List /setting/users | client-side keyword filter over GET /users; role names from GET /roles; store codes from GET /store (page size 1000); state badge | web:src/pages/setting/users/index.tsx, web:src/services/users/index.ts:17-45 |
Detail /setting/users/[id] | finds the user in the full list; role checkboxes; "All stores" switch + StoreSelect. Shows the union of held role keys, or "All permissions" if system_admin is held. Unlock / enable / disable / save behind <Can user.update> | web:src/pages/setting/users/[id].tsx, web:src/services/users/index.ts:47-137 |
| Client check | staff with no store and not ALL → store_required message, no request | web:src/services/users/index.ts:86-88 |
| Errors | apiErrorMessage(e, 'user_error') maps code to a translation | web:src/utils/error.ts:3-7 |
| StoreSelect | one-line summary (2 names + +N), searchable checkbox list with picked on top, "Select all matching", "Clear" | web:src/components/business/store/StoreSelect/index.tsx |
Web API proxy routes
| Next route | Core call |
|---|---|
GET /api/permissions | GET /permissions |
GET/POST /api/roles | GET/POST /roles |
PUT/DELETE /api/roles/[id] | PUT/DELETE /roles/:id |
GET /api/users | GET /users |
PUT /api/users/[id]/access, POST /api/users/[id]/{unlock,enable,disable} | same under /users |
GET/PUT /api/user/me | GET /auth/me (with store retry) / PUT /user/me |
POST /api/auth/login, POST /api/auth/revoke | POST /auth/access_token, POST /auth/revoke |
These proxies return e.data (full error body, so code survives). The generic /api/entity/[entity] proxy returns only e.error (the message array), which drops code.
11. Tests
Core (jest)
| Spec | Covers |
|---|---|
src/common/guards/auth.guard.spec.ts | key shapes (4 + verbs + C1/C2/C6), keyless routes, registry listing/labels, seeded-role key sets, guard decisions (pass, 403, system_admin, union, deprecated key, public with bad token, 401, logged-in, API key, general.*), actor from session, X-Store-Id on a session built from the real login (C13) |
src/auth/auth.me.spec.ts | /auth/me shape for system_admin / staff / ALL / no scopes; self-service routes keyless; stores ignores X-Store-Id (C11) |
src/auth/auth.service.spec.ts | login has no app/role check (C5–C7); inactive refused |
src/role/role.service.spec.ts | seed C1/C2/C8–C10, user backfill C3/C4 + per-role user_type, app backfill C10/C14, seed key sets, Nest boot order |
src/role/role.crud.spec.ts | save/version/409 (incl. 20 concurrent runs), unknown keys, audit diff, system role lock, delete rules, list counts, 404 |
src/role/role.access.spec.ts | warm path one Redis lookup / 0 DB reads, Redis failure → DB, deleted role, cold fill, union |
src/user/user.admin.service.spec.ts | assign + cache drop + audit, self change, store_required, ALL, unknown ids, last system_admin (sequential and parallel), unlock/enable/disable, system user 404 |
src/user/user.service.spec.ts | system user seed races, legacy role fix, createUser role_ids |
src/common/scope/scope.spec.ts | base-service scope on every path, operator bypass (C9), X-Store-Id, create fill, aggregate cast (C8), empty scopes, system/Kafka unrestricted, DO item copy, trial zone scope, scoped-entity snapshot |
src/common/scope/scope.bypass.spec.ts | plan history, plan optimize, DO update by delivery no, script runner async context, sweep + system user as system actor |
Web (jest + RTL)
| Spec | Covers |
|---|---|
web:src/utils/__tests__/permission.test.ts | menu gating (all, role.read only, hidden item, empty group, no key), store can() |
web:src/utils/__tests__/store_scope.test.ts | stale/kept/malformed cookie, ALL hint cookie, form default |
web:src/apis/server/__tests__/store_header.test.ts | header forwarding, store_forbidden retry, 404/5xx thrown (fail closed) |
web:src/__tests__/pages/permission-gate.test.tsx | no-access home, <Can>, 403 toast without logout |
web:src/__tests__/pages/setting-roles.test.tsx | grouping, system_admin locked, delete visibility, whole-array save with version, conflict reload, unlabelled group, create |
web:src/__tests__/pages/setting-users.test.tsx | store_required client block, system_admin union, union of keys, 64 stores one line, select matching / clear, ALL switch, server error message, unlock (with web-only state locked) |
web:src/__tests__/pages/top-bar.test.tsx | picker defaults/pick/clear/single/ALL search, badge, logout with failing revoke, form default |
12. Gaps and known issues
| # | Gap | Evidence | Impact |
|---|---|---|---|
| G1 | Web key list diverges from the registry. web:src/constants/permission-keys.ts is hand-written, not generated by permissions:types. 9 of its 24 keys do not exist in core: application.read, setting.attachments.read (core setting.attachment.read), setting.export_configs.read (core setting.export_config.read), setting.templates.read (core setting.template.read), setting.authen.read, setting.google.read, setting.storage.read, setting.outbound_events.read, setting.script_settings.read | web:src/constants/permission-keys.ts:2-25 vs §4 registry; used at web:src/constants/menu.ts:89-137 and the 8 setting pages | Every non-system_admin, including the seeded admin that holds all 55 keys, loses 8 Setting menu items and gets the 403 page on them. The design's "typo fails tsc" protection is absent |
| G2 | Web user states do not match core. Web expects active/locked/disabled, core returns active/locked_out/inactive/staged | web:src/apis/client/users/types.ts:10, web:src/constants/user.ts, web:src/pages/setting/users/[id].tsx:48,65 vs src/common/enums/user.enum.ts:1-7 | Unlock button never shows. A disabled user shows "Disable" instead of "Enable". Badges show raw keys with no colour. The users test asserts locked, a value core never sends |
| G3 | Design wants special-route keys (plan.re_plan, jobs retry, schedules enable); code has no such @Permission, so all plan POST actions share plan.create, schedules enable/disable = setting.schedules.create, jobs retry/skip = setting.jobs.create | §4 registry; design tms-v2-auth.md §2 "route พิเศษ" | A role cannot allow creating a plan without also allowing re_plan / assign / generate_task |
| G4 | D-585 text says new registry keys are not added to admin automatically; code $addToSets every registry key into an unedited admin (version 1) on every boot | src/role/role.service.ts:47-52 | Unedited admin silently gains each new key after deploy (code wins; the commit message states this) |
| G5 | Users are unscoped: GET /users and GET /user return every user to any user.read holder, whatever the caller's stores | src/user/user.admin.service.ts:30-33; snapshot user: unscoped | Design §7 "staff see users of their stores" not built |
| G6 | Picker and Users store options call GET /store, which is @Scoped({store:'_id'}) and narrowed by X-Store-Id | src/common/scope/scope.ts:28, web:src/components/StorePicker/index.tsx:28-31, web:src/services/users/index.ts:17-24 | With a store picked, an ALL user's picker search and an admin's Users store list show only that one store (read from code, not verified live) |
| G7 | Existing DOs have no source_store_id backfill (plans have one) | src/plan/plan.module.ts:47-53; none for DO | Pre-existing DOs are invisible to every store-scoped user; source_store_id has no index |
| G8 | Moving a DO to another store does not update its items' store_id | src/delivery_order_item/delivery_order_item.service.ts:64 only at create | Items stay visible under the old store |
| G9 | POST /user (driver master page) sets role_ids but no scopes | src/user/user.service.ts:208-212 | New staff user sees no scoped data until an admin sets stores |
| G10 | Actor comes from legacy session.user.role (login snapshot); users with roles outside UserRole or no role get no actor | src/common/context/actor.ts:33-45 | Their writes are attributed to the system user |
| G11 | Disabled users keep live tokens (TTL 1 year); revoke is not awaited and has no per-user index | src/auth/auth.service.ts:124-128,172 | Accepted for this round (D-578); login round |
| G12 | applications.enabled not checked; secrets stored and compared in plain text; Basic auth reads Mongo on every request | src/common/guards/auth.guard.ts:60-75, src/auth/auth.service.ts:59-82 | Design §6.1 not built (login round) |
| G13 | Non-default apps holding exactly [system_admin] are reset to [] on every boot | src/application/application.service.ts:81 | A future "grant roles to an app" API must remove or mark this (review r2 NOTE) |
| G14 | Role delete checks users only, not applications | src/role/role.service.ts:165 | App keeps a dangling id (harmless, contributes no keys) |
| G15 | Generic web entity proxy returns only the message, dropping code; core 401 is no longer the "Forbidden resource" string the client redirects on | web:src/pages/api/entity/[entity]/index.ts, web:src/apis/client/request.ts:39-44 | permission_denied toast never fires for entity routes. An expired token mid-session shows an error instead of going to /login until the next SSR load |
| G16 | /user/me (GET) requires user.read; /user/me vs /user/:user_id shadowing noted in design is not changed (Fastify gives static routes priority, not verified live) | src/user/user.controller.ts:37-49 | Self-profile reads go through /auth/me instead |
| G17 | _initialSystemUser not awaited before user backfill | src/user/user.service.ts:35-37 | Fresh db: the system_admin login user may lack role_ids until a second boot |
| G18 | users.role_ids not indexed (countByRoles, holdsRole, _keepOneAdmin scan); plan history scope runs distinct('code') over all visible plans per request | src/user/user.schema.ts:40-41, src/plan_history/plan_history.service.ts:31-35 | Slow on large data |
| G19 | Attachments stay unscoped (D-579); driver own-work filtering and partner scope are not built (D-537) | snapshot; design §3.6 driver row | Driver sees whatever scopes.store allows |
| G20 | /auth/me for a Basic (API key) caller returns user.id: 'undefined' | src/auth/auth.me.ts:29 | Cosmetic |
| G21 | Web token cookie maxAge gets expires_in (seconds) where the cookies lib expects ms (pre-existing) | web:src/pages/api/auth/login.ts:18 | Cookie lives ~8.8 h, not 1 year |
| G22 | /setting/storage, /setting/outbound-events, /setting/script_settings call /storage-settings, /outbound_events, /script_settings; no such controller exists at this core sha | web:src/apis/server/{storage_setting,outbound_event,script_setting}/index.ts | Pages 404 against this core build regardless of keys |
13. How to add the next one
A new guarded controller (e.g. trip)
- Put
@Resource('trip')on the class (one resource per controller; usesetting.<area>for setting screens). Copysrc/vehicle/vehicle.controller.ts. - Leave verb defaults (
trip.read/create/update/delete). Add@Permission('cancel', 'Cancel trip')on a route that needs its own key (shape:src/user/user.admin.controller.ts:15-42). Do not put@Resourceon a handler. - Self-service routes any logged-in user needs get
@LoggedIn(). Open routes get@Public()(both insrc/auth/auth.controller.ts). - Restart. The key appears in
GET /permissionsand on the Roles page with no web change. An unedited seededadmingains it at boot. Edited roles do not. - Regenerate web types:
npm run permissions:types -- <web>/src/constants/permission-keys.ts(writes the union; replace the hand-written file, which closes G1). - Web: add
permission: 'trip.read'to the menu entry (web:src/constants/menu.ts),export default withPermission(TripPage, 'trip.read'), and wrap actions in<Can permission="trip.cancel">. Optionally addpermission_group_triptoweb:src/utils/translate.ts. - Tests: add the key-shape case to
src/common/guards/auth.guard.spec.tsand a menu case toweb:src/utils/__tests__/permission.test.ts.
A new store-scoped entity
- Give the schema a scope field (
store_id, string,ref: 'store',index: true). Copysrc/plan/plan.schema.ts:147-148. A child entity copies it from the parent at save (src/delivery_order_item/delivery_order_item.service.ts:64). - Decorate every service over that collection with
@Scoped({ store: 'store_id' }), sharing one map constant (PLAN_SCOPE,src/plan/plan.schema.ts:180-181).BaseServicethen scopes every path. - Any query that bypasses
BaseService(this.model.*,collection.*, aggregate on another model, sandbox callbacks) must ANDwithScope(this._scope, filter)orscopeFilter(MAP)and callassignScope(map, data, false)on update bodies (src/delivery_order/delivery_order.service.ts:140-149,src/plan_history/plan_history.service.ts:31-35). - Non-request work (jobs, schedules, Kafka) is unrestricted by channel. Explicit cross-scope reads inside a request use
asSystem(() => ...)(src/auth/auth.service.ts:184). - Add a boot backfill for existing rows (
src/plan/plan.service.ts:32-43, called from the module'sonModuleInit). - Update the snapshot in
src/common/scope/scope.spec.ts(scoped-entity listing) and add list / by-id 404 / foreign-store create 403 /$setoperator cases for the entity. - Web create form: prefill the store field with
useDefaultStoreCode()(web:src/hooks/store_scope/index.ts).
A new scope dimension (e.g. partner)
Add scopes.partner to the user record and the admin DTO. Then use @Scoped({ store: 'store_id', partner: 'partner_id' }) on the entity. No base-service change is needed (proven by the trial zone scope, src/common/scope/scope.spec.ts:228). A selected-value header like X-Store-Id exists only for store (src/common/scope/scope.ts:28, src/common/guards/auth.guard.ts:26-40).