Skip to content

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.

RepoWorktreeBranchSHADiff baseFolders
bigc-tms-core-v2 ("core")/Users/thanosorn/Works/Software/tms/wt-v2-auth-corefeature/auth (not merged to main-v2-prototype)9d9422c3d70475src/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-storefeature/authd9fd0820851a3asrc/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 ​

FileResponsibility
src/common/guards/auth.guard.tsGlobal 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.tsHTTP verb → suffix map, key builder, general fallback resource
src/common/permission/permission.registry.tsBoot scan of every controller via DiscoveryService; route → key map; key list for GET /permissions
src/common/permission/permission.controller.tsGET /permissions
src/common/permission/permission.types.tsCLI npm run permissions:types -- <out.ts> [--routes]: writes the PermissionKey union from built controllers
src/common/permission/permission.module.tsRegistry provider + controller
src/common/scope/scope.ts@Scoped(map), scopeFilter, withScope, assignScope, asSystem
src/common/base/base.service.tsApplies withScope/assignScope to every read/write path of a @Scoped service
src/common/context/request_context.tsAsyncLocalStorage store: actor, channel, access, store_id, system
src/common/context/context.middleware.tsFastify onRequest hook opens an http context per request
src/common/context/actor.tsActor from session (legacy user.role), system actor
src/common/enums/user.enum.tsUserState, 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.tsToken issue/revoke, OTP verify, /auth/me store resolution
src/auth/auth.me.tsMeOutput shape, scopedStoreIds, buildMe
src/auth/auth.settings.tsauthen.enable_universal_otp, authen.universal_otp settings
src/role/role.schema.tsroles collection
src/role/role.dto.tsCreateRoleInput, UpdateRoleInput
src/role/role.controller.ts/roles CRUD
src/role/role.service.tsSeed, user backfill, role CRUD with version check, audit, cache invalidation
src/role/role.seed.tsSeed role list and keys; userTypeOf(role, system)
src/role/role.access.tsRoleAccessService.forUser/forApplication → CallerAccess
src/role/role.cache.tsRedis cache of roles and user access (Lua lookup, invalidate, flush)
src/role/role.constants.tsCache TTL/timeout/re-delete env knobs
src/role/role.module.tsImports UserModule, PermissionModule, ActivityLogModule
src/user/user.schema.tsusers collection (+ role_ids, user_type, scopes)
src/user/user.service.tsSystem 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.tsUpdateUserAccessInput with IsStoreScope validator
src/user/user.admin.service.tsAccess/unlock/enable/disable in a transaction, last-system-admin guard, audit
src/user/user.controller.tsLegacy /user routes (search, by user_id, me, create)
src/application/application.schema.tsapplications collection (+ role_ids)
src/application/application.service.tsDefault 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.tsConsumers of the scope helpers (see §5)
src/common/utils/redis.tsredisSession / redisCache clients; eval, onReady added

Web ​

FileResponsibility
web:src/pages/_app.tsxSSR 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.tszustand profile: profile, all, permissions:Set, selectedStore, can()
web:src/hooks/permission/index.tsusePermission() → { can, hasNoKey }
web:src/components/Can/index.tsx<Can permission> hides children
web:src/utils/permission.tsprofileCan (SSR), withPermission(page, key), groupPermissions
web:src/constants/permission-keys.tsHand-written PermissionKey union (diverges from core, see §12)
web:src/constants/menu.ts, web:src/utils/menu.ts, web:src/hooks/menu/index.tsMenu with permission per entry; prepareMenu drops forbidden entries and empty groups
web:src/utils/store_scope.tsCookie names, resolveSelectedStore, defaultStoreCode, writeStoreCookies
web:src/hooks/store_scope/index.tsuseDefaultStoreCode() for create forms
web:src/apis/server/request.tsgetHeadersFromRequest forwards X-Store-Id; clearStoreCookies
web:src/apis/server/user/index.tsrequestMyProfile (/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.tsNext API proxy routes
web:src/apis/client/request.ts403 permission_denied → toast (no logout)
web:src/pages/setting/roles/index.tsx, web:src/services/role/index.tsRoles page + state hook
web:src/pages/setting/users/index.tsx, [id].tsx, web:src/services/users/index.tsUsers list / detail + hooks
web:src/components/StorePicker/index.tsxTop-bar store picker
web:src/components/UserBadge/index.tsxName, roles, logout
web:src/components/business/store/StoreSelect/index.tsxOne-line multi-store field (Users detail)
web:src/components/business/role/Item/index.tsx, web:src/components/ui/CounterPill/index.tsxRole list row, n/total pill
web:src/components/layouts/Main/Header/index.tsxMounts 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.tsxDesign draft fixtures (not wired to real data)

3. Data model ​

roles (src/role/role.schema.ts, strict, timestamps created_at/updated_at) ​

FieldTypeReqDefaultValidationIndexNote
codestringyes—create DTO ^[a-z][a-z0-9_]*$ (src/role/role.dto.ts:5); system_admin refused on createunique (:29)immutable (no update path)
namestringyes—IsNotEmpty—
permissionsstring[]—[]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_systemboolean—false——true for the 5 seeded roles
versionnumber—1update DTO IsInt Min(1)—optimistic lock, $inc on every save
holder_revnumber—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):

codenamepermissions at seed
system_adminSystem Admin[] (passes all by code)
adminAdminevery registry key (55 today)
store_operatorStore Operatordelivery_order.read, plan.read, store.read
transport_operatorTransport Operator[]
driverDriver[]

users (src/user/user.schema.ts) ​

FieldTypeReqDefaultEnum / validationIndexNote
user_idstringyesuuid on create—uniqueexternal id
usernamestringyes—lower-cased at loginunique (:53)
first_name, last_namestring————
rolestring——legacy UserRole—kept, still drives the actor (src/common/context/actor.ts:36)
statestring—active with password, else staged (src/user/user.service.ts:199-201)staged/active/inactive/locked_out/suspended—only active may log in
passwordstring——sha256(password:created_at) via generateHash—
systemboolean————true only for the boot system user
role_idsObjectId[] ref role—undefined (absent)admin API: each IsMongoId, must existno indexabsent = "not backfilled yet"
user_typestring——system/internal/staff/driver—set by backfill / createUser
scopesMixed { store?: 'ALL' | string[] }——admin API IsStoreScope (src/user/user.admin.dto.ts:8-14)—store ids saved as strings
last_logged_in_atDate————set on login

applications (src/application/application.schema.ts) ​

FieldTypeReqNote
namestringyesunique
codestringyesunique (:46); default = backend-to-backend key
app_keystring—unique (:45); default app takes DEFAULT_APP_KEY
secret_keystring—plain text, masked in responses (@MaskFields)
enabledboolean—not checked at login or Basic auth
rolesstring[]—legacy, ignored by the guard
role_idsObjectId[] ref role—the API key's own roles; default app [system_admin], others []
enable_2fa, require_deviceboolean—login flow switches

Scoped-entity fields added by this part ​

CollectionFieldIndexFilled by
delivery_orderssource_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_itemsstore_id (string, ref store)yescopied from the DO at save (src/delivery_order_item/delivery_order_item.service.ts:64)
plansstore_id (string, ref store)yescreatePlan (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 ​

ClientKeyValueTTLWrittenDeleted
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)
redisSessionOTP_<ref>OTP payloadfrom utils/otp2FA loginOTP 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 forUserrole update/delete after commit + re-delete after 1 s; role:* flush on every Redis ready
redisCacheuser_access:<userId> (:10){ role_ids[], scopes, user_type }ACCESS_CACHE_TTLcache miss in forUseruser 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 + routeKeyRequestResponseErrorsCode
POST /api/v1/auth/access_tokenpublicbody { 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/otppublic{ otp }{ token, expires_in }from validateOtpsrc/auth/auth.service.ts:136-165
POST /api/v1/auth/revokepublic{ token }{ status: true } alwaysnonesrc/auth/auth.service.ts:167-176
GET /api/v1/auth/melogged inheaders 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 + routeKeyRequestResponseErrorsCode
GET /api/v1/permissionsrole.read—{ items: [{ key, resource, suffix, label }] } sorted by key401/403src/common/permission/permission.controller.ts:13-16
GET /api/v1/rolesrole.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/:idrole.read—one item404 not_found:98-102
POST /api/v1/rolesrole.create{ code, name, permissions[] }item, user_count: 0, version 1, is_system false403 system_role_locked (code system_admin); 400 unknown_permission + keys; 409 duplicate_code; 400 validation:104-125
PUT /api/v1/roles/:idrole.update{ name?, permissions[] (whole array), version }item after save404; 403 system_role_locked; 409 rev_conflict + version (current); 400 unknown_permission:128-159
DELETE /api/v1/roles/:idrole.delete—deleted item, user_count 0404; 403 system_role_locked (any is_system); 409 role_in_use; 409 rev_conflict:161-180

Users admin ​

Method + routeKeyRequestResponseErrorsCode
GET /api/v1/usersuser.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/accessuser.update{ role_ids: mongoId[], scopes: { store: 'ALL' | storeId[] } }user item403 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/unlockuser.update—user item404:52-54
POST /api/v1/users/:id/enableuser.update—user item403 self; 409 invalid_state (staged); 404:56-62
POST /api/v1/users/:id/disableuser.update—user item403 self; 409 last_system_admin; 404:64-71

Legacy user routes (touched by this part) ​

Method + routeKeyNote
GET /api/v1/user, GET /api/v1/user/:user_id, GET /api/v1/user/meuser.read/user/me needs user.read, not just a login (src/user/user.controller.ts:44-49)
POST /api/v1/useruser.createcreateUser 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/melogged infirst/last name only
POST /api/v1/device/heartbeatlogged insrc/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.

KeyResourceRoute(s)
activity_log.readactivity_logGET /activity_logs, GET /activity_logs/:entity/:entity_id
delivery_order.createdelivery_orderPOST /delivery_order
delivery_order.readdelivery_orderGET /delivery_order, GET /delivery_order/:delivery_no
delivery_order.updatedelivery_orderPUT /delivery_order/:delivery_no
delivery_order_item.createdelivery_order_itemPOST /delivery_order_item
delivery_order_item.deletedelivery_order_itemDELETE /delivery_order_item/:item_id
delivery_order_item.readdelivery_order_itemGET /delivery_order_item, GET /delivery_order_item/:item_id, GET /delivery_order_item/order/:delivery_no
delivery_order_item.updatedelivery_order_itemPUT /delivery_order_item/:item_id
device.readdeviceGET /device
plan.createplanPOST /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.readplanGET /plan, GET /plan/:code
plan_history.readplan_historyGET /plan_history, GET /plan_history/plan/:code
plan_template.createplan_templatePOST /template/plan
plan_template.readplan_templateGET /template/plan, GET /template/plan/:code
plan_template.updateplan_templatePUT /template/plan/:code
role.createrolePOST /roles
role.deleteroleDELETE /roles/:id
role.readroleGET /roles, GET /roles/:id, GET /permissions
role.updaterolePUT /roles/:id
setting.application.createsetting.applicationPOST /application
setting.application.deletesetting.applicationDELETE /application/:code
setting.application.readsetting.applicationGET /application, GET /application/:code
setting.application.updatesetting.applicationPUT /application/:code, PATCH /application/revoke
setting.attachment.createsetting.attachmentPOST /attachments, POST /attachments/:id/confirm
setting.attachment.readsetting.attachmentGET /attachments, GET /attachments/:id, GET /attachments/:id/content
setting.export_config.createsetting.export_configPOST /export_configs
setting.export_config.readsetting.export_configGET /export_configs, GET /export_configs/:id
setting.export_config.updatesetting.export_configPUT /export_configs/:id
setting.field_catalog.readsetting.field_catalogGET /export_fields, GET /export_fields/:entity
setting.general.createsetting.generalPOST /settings/:code/reset
setting.general.readsetting.generalGET /settings, GET /settings/:code
setting.general.updatesetting.generalPUT /settings/:code
setting.jobs.createsetting.jobsPOST /jobs/:id/retry, POST /jobs/:id/skip
setting.jobs.readsetting.jobsGET /jobs
setting.kafka.createsetting.kafkaPOST /kafka/test-connection
setting.kafka.readsetting.kafkaGET /kafka/status
setting.schedules.createsetting.schedulesPOST /schedules/:code/enable, POST /schedules/:code/disable
setting.schedules.deletesetting.schedulesDELETE /schedules/:code/cron_override
setting.schedules.readsetting.schedulesGET /schedules
setting.schedules.updatesetting.schedulesPUT /schedules/:code/cron_override
setting.template.createsetting.templatePOST /templates, POST /templates/render
setting.template.readsetting.templateGET /templates, GET /templates/:id
setting.template.updatesetting.templatePUT /templates/:id
store.createstorePOST /store
store.readstoreGET /store, GET /store/:code
store.updatestorePUT /store/:code
user.createuserPOST /user
user.readuserGET /user, GET /user/:user_id, GET /user/me, GET /users
user.updateuserPUT /users/:id/access, POST /users/:id/unlock, POST /users/:id/enable, POST /users/:id/disable
vehicle.createvehiclePOST /vehicle
vehicle.readvehicleGET /vehicle, GET /vehicle/:code
vehicle.updatevehiclePUT /vehicle/:code
vehicle_type.createvehicle_typePOST /type/vehicle
vehicle_type.readvehicle_typeGET /type/vehicle, GET /type/vehicle/:code
vehicle_type.updatevehicle_typePUT /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 atTest
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 validsrc/common/guards/auth.guard.ts:100-104src/common/guards/auth.guard.spec.ts:172 (C5)
a guarded route gets no token, or a token not in Redis, or a bad Basic pair401 unauthorizedauth.guard.ts:78-86,105auth.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_idsauth.guard.ts:60-75,88-93auth.guard.spec.ts:191 (P4)
a route has @LoggedIn()pass any valid token without a keypermission.registry.ts:65,72, auth.guard.ts:111auth.guard.spec.ts:185, src/auth/auth.me.spec.ts:47
the caller holds a role with code system_adminpass every keysrc/role/role.access.ts:35, auth.guard.ts:111auth.guard.spec.ts:152
the route key is in the union of the caller's role keyspass; otherwise 403 permission_denied with keyauth.guard.ts:111-112, role.access.ts:36auth.guard.spec.ts:138,146,158; src/role/role.access.spec.ts:63
a role id on the caller no longer existsit contributes no keys, no errorrole.access.ts:90-96role.access.spec.ts:46
a route has no @Permissionsuffix = GET read, POST create (any POST path), PUT/PATCH update, DELETE delete, else the lower-case verbsrc/common/permission/permission.key.ts:5-17auth.guard.spec.ts:64-98
a controller has no @Resourceprefix generalpermission.key.ts:3,19-20, permission.registry.ts:69auth.guard.spec.ts:72,76,208
@Resource is put on a handlerignore it, keep the class resource, log a warningpermission.registry.ts:66-69auth.guard.spec.ts:90 (C2)
two routes share a keylist the key once; a later @Permission label replaces a fallback labelpermission.registry.ts:75-80auth.guard.spec.ts:110
a handler was not seen at bootregister it lazily on first requestpermission.registry.ts:58-61indirect (guard spec uses test controllers)
X-Store-Id is presentit must be 24-hex; the lower-cased id is usedauth.guard.ts:14,26-32src/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 existsauth.guard.ts:33-37auth.guard.spec.ts:261 (C13, real login path), scope.spec.ts:264
an ALL holder, system_admin or API key sends any storeaccept it only if the store exists; unknown → 403auth.guard.ts:38, src/store/store.service.ts:24-26scope.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/deleteManysrc/common/base/base.service.ts:67, :173-188, src/common/scope/scope.ts:36-52scope.spec.ts:72,83,87,156,163
a by-id get/update/delete targets an out-of-scope doc404 not_found, doc unchangedbase.service.ts:106-113,177,228-230,244-247scope.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 keysscope.ts:28scope.spec.ts:124,128,134
the context is not http, is system, has access.all, or comes from an API keyno scope restriction (apart from a selected store)scope.ts:23-24scope.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-32scope.spec.ts:178,185
a create on a scoped entity has no scope valuefill it from the single allowed id (selected store or the only store); several or ALL → 400 store_requiredscope.ts:83-91scope.spec.ts:139,146,151
a create/update names a scope value the caller may not use403 store_forbiddenscope.ts:84-86scope.spec.ts:102,139
an update body carries $set/$setOnInsert on the scope fieldcheck the value like a plain field; null/'' → 403scope.ts:59-71scope.spec.ts:106
an update body carries any other operator ($unset, $rename to/from, $inc …) on the scope field, or a non-object operator value403scope.ts:62-66scope.spec.ts:106,116
aggregate runs on a scoped serviceprepend a $match cast through the schema, so string ids match ObjectId fieldsbase.service.ts:171-177scope.spec.ts:168 (C8)
a DO is updated by delivery noapply assignScope(..., false) and a scoped findOne inside the DO transaction; missing → 404src/delivery_order/delivery_order.service.ts:140-149src/common/scope/scope.bypass.spec.ts:89
a DO item is createdcopy store_id from its DOdelivery_order_item.service.ts:64scope.spec.ts:220 (C5)
plan history is readreturn rows only for plan codes visible under the plan scopesrc/plan_history/plan_history.service.ts:31-35,51-62scope.bypass.spec.ts:63
plan optimize saves a planscope the findOneAndUpdatesrc/plan/plan_optimize.service.ts:913scope.bypass.spec.ts:72,78,84
the script sandbox calls a reader backrun it in the caller's async context (AsyncResource.bind)src/script_runner/script_runner.service.ts:132-135scope.bypass.spec.ts:95
the attachment sweep runsrun as system actorsrc/attachment/attachment.sweep.processor.ts:45-46scope.bypass.spec.ts:108
a role is created with code system_admin, or the system_admin row is updated/deleted403 system_role_locked (even for system_admin)role.service.ts:105,130,163src/role/role.crud.spec.ts:139
a role save carries a key not in the registry and not already on the role400 unknown_permission with keysrole.service.ts:133,188-191role.crud.spec.ts:78,116
a role save sends a stale version409 rev_conflict with the current version; nothing writtenrole.service.ts:131,139-148role.crud.spec.ts:85,123
a role is savedwrite activity_logs {entity:'role', action:create/update/delete, changes.permissions{added,removed}} in the same transactionrole.service.ts:116-121,149-155,170-176role.crud.spec.ts:95,102
an admin grants a key they do not holdallow itno check existsrole.crud.spec.ts:110
a seeded role (is_system) is deleted403 system_role_lockedrole.service.ts:163role.crud.spec.ts:146
a custom role is still on any user409 role_in_use (applications are not checked)role.service.ts:165-167, src/user/user.service.ts:127-129role.crud.spec.ts:146
an admin changes their own access, or enables/disables themselves403 self_change_forbidden (unlock of self is allowed)src/user/user.admin.service.ts:36,57,65,143-145src/user/user.admin.service.spec.ts:88
a user that is not system/internal is saved with an empty store list400 store_required (drivers too)user.admin.service.ts:40-42user.admin.service.spec.ts:95
access names an unknown role or store id400 unknown_role / unknown_storeuser.admin.service.ts:122-132user.admin.service.spec.ts:109
system_admin is removed from (or disable hits) an active holder and no other active non-system holder remains409 last_system_adminuser.admin.service.ts:45-46,68,111-120user.admin.service.spec.ts:118,139,150,157
a staged user is enabled409 invalid_stateuser.admin.service.ts:59untested
unlock/enable/disable would not change the statewrite nothing but still log the actionuser.admin.service.ts:53,60,67,84-93user.admin.service.spec.ts:181
any user admin change commitsdrop user_access:<id>, then re-drop it after 1 suser.admin.service.ts:94, role.cache.ts:90-97user.admin.service.spec.ts:65
a user doc is updated through UserService.update without a sessiondrop its access cachesrc/user/user.service.ts:100-104indirect
a user logs inno app or role check; only state === active and the password mattersrc/auth/auth.service.ts:94-97, src/user/user.service.ts:146-164src/auth/auth.service.spec.ts:18-34
/auth/me is answeredstores = every held store, ignoring X-Store-Id; permissions=[] when allauth.service.ts:179-187, auth.me.ts:20-40auth.me.spec.ts:16-85
a user is created via POST /user with an existing legacy rolerole_ids=[that role], user_type from userTypeOfuser.service.ts:208-212src/user/user.service.spec.ts:79

6. States and flows ​

User state (as built) ​

FromActionToGuard
(new, no password)createUserstaged—
(new, password)createUseractive—
locked_outunlockactive—
any otherunlockunchanged (logged)—
inactive / locked_out / suspendedenableactivenot self
stagedenable—409 invalid_state
active / locked_out / suspended / stageddisableinactivenot 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 ​

NameTriggerPayloadRetryIdempotency
Role seed (RoleService.onApplicationBootstrap)every bootregistry keysnone; duplicate-key from a parallel pod swallowedupserts by code, filters on version: 1
User backfillevery boot after seed—noneonly role_ids: {$exists:false}
App backfill (ApplicationService.onApplicationBootstrap)every bootsystem_admin role idnonefilters; the [system_admin] reset re-applies every boot
System user ensure (UserService.onModuleInit)every boot—duplicate-key → re-readupsert on {username:'system', system:true}
system_admin login user (_initialSystemUser)every boot, not awaitedDEFAULT_SYSTEM_ADMIN_PASSWORDnonefind-then-create (no duplicate guard beyond the unique index)
Plan store_id backfill (PlanModule.onModuleInit)every boot, not awaited—errors loggedstore_id: {$exists:false}
Access cache flushevery Redis ready (first connect and each reconnect) per podrole:*, user_access:*errors loggedSCAN + DEL
Attachment sweepexisting Bull job—existingnow 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 ​

KeyDefaultEffectRead at
SESSION_REDIS_URLrequiredtoken storesrc/common/utils/redis.ts:177
CACHE_REDIS_URLrequiredrole / user-access cacheredis.ts:178
ACCESS_CACHE_TTL3600 scache entry lifetimesrc/role/role.constants.ts:2
ACCESS_CACHE_TIMEOUT_MS150cache call slower than this → DBrole.constants.ts:5
ACCESS_CACHE_REDELETE_MS1000delay of the second invalidationrole.constants.ts:8
DEFAULT_APP_KEY / DEFAULT_SECRET_KEYrandomdefault application credentials on first bootsrc/application/application.service.ts:61-62
DEFAULT_SYSTEM_ADMIN_PASSWORD—password of the system_admin login user on first bootsrc/user/user.service.ts:84
ACCESS_TOKEN_TTL (const)1 yeartoken TTL and expires_insrc/common/constants/auth.constant.ts:2
setting authen.enable_universal_otpfalseallow universal OTPsrc/auth/auth.settings.ts:10, auth.service.ts:52
setting authen.universal_otp (encrypted JSON list)nulluniversal OTP codesauth.settings.ts:12, auth.service.ts:45-53
web COOKIE_SAME_SITE, COOKIE_SECURE, COOKIE_HTTP_ONLYlax / false / falsetoken cookie flagsweb: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 ​

SituationWhat the code doesWhere
Two pods boot on an empty dbrole 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 swallowedsrc/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 versionupdateOne({_id, version}) inside a tx; the loser gets 409 with the current versionrole.service.ts:139-148; src/role/role.crud.spec.ts:123 (20 runs)
Role edited on pod A, request on pod Bcache is shared Redis only (no in-pod memory); role:<id> deleted after commit, so B reads the new keys on its next requestsrc/role/role.cache.ts:90-95
Reader refills the cache from pre-commit state during a savesecond delete after ACCESS_CACHE_REDELETE_MSrole.cache.ts:92
Redis cache down or slowevery 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 entryrole.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 downredisSession.get throws → the guard throws (500 on guarded routes); public routes swallow the errorsrc/common/guards/auth.guard.ts:43,102
Two admins remove system_admin from the last two holders in paralleleach 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 409src/user/user.admin.service.ts:110-120; src/user/user.admin.service.spec.ts:157
Process dies after a commit, before afterCommitthe cache entry stays stale until TTL (≤ 1 h) or the next Redis ready flushsrc/common/utils/transaction.ts:24-30
User disabled while holding live tokenstokens 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 usernext request, no re-login: access comes from the user record and cache, never the login snapshotrole.access.ts:56-79
Same request processed twicerole/user writes are not idempotent by request id; a retried role PUT with the old version gets 409—
Boot-time _initialSystemUser vs user backfillnot awaited, so on a fresh db the system_admin login user may be created after backfillUsers and stay without role_ids until the next bootsrc/user/user.service.ts:35-37

10. Web (store-v2) ​

Permission plumbing ​

PieceBehaviourWhere
Profile loadSSR only (getInitialProps on the server). On client navigations the zustand store keeps the SSR profileweb:src/pages/_app.tsx:122-177
Fail closed404/5xx/timeout of /auth/me → profile with no keys; 401/403 → token cleared (login redirect)_app.tsx:139-149
Page gatewithPermission(Page, key) sets Page.permission. SSR sets status 403. Render shows ForbiddenPageweb: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 gateentry hidden without its key; a group with no children left is dropped; order keptweb:src/utils/menu.ts:5-20, web:src/constants/menu.ts
No key at allhome page shows "no access" in the ErrorPage layout; menu is emptyweb:src/pages/index.tsx
API 403permission_denied → toast, no logout; legacy "Forbidden resource" body → login redirectweb:src/apis/client/request.ts:39-44
Error pages403/404/500/_error share ErrorPage with an illustration and a home linkweb: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 ​

PieceBehaviourWhere
Header forwardevery server proxy call adds X-Store-Id from cookie store_id when it is 24-hexweb:src/apis/server/request.ts:54-81
Stale cookiea cookie not in /auth/me stores, malformed, or rejected with store_forbidden → cookies cleared, picker on defaultweb:src/utils/store_scope.ts:27-43, web:src/apis/server/user/index.ts:12-20, _app.tsx:153-162
Login / logoutlogin clears store cookies. Logout clears token + store cookies even when revoke fails, then the badge goes to /loginweb:src/pages/api/auth/login.ts:12, web:src/pages/api/auth/revoke.ts:8-27, web:src/components/UserBadge/index.tsx:23-28
StorePickerone store → name only. Several → "All my stores" + list. ALL → search GET /store (page size 50, 300 ms debounce). Pick → write cookies + full reloadweb:src/components/StorePicker/index.tsx
Create-form defaulttop-bar store, else first profile store; DO form sets source_store.code, plan form sets store.codeweb: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) ​

ElementBehaviourWhere
ListCompactList of GET /roles: name (code in tooltip), lock icon for system roles, user countweb:src/components/business/role/Item/index.tsx
Detailcode (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 rawweb:src/pages/setting/roles/index.tsx:16-151, web:src/utils/permission.ts:13-21
system_admin"All permissions" locked panel, no Saveroles/index.tsx:43,84,114-121
Savecreate {code,name,permissions} (role.create); update {name,permissions,version} (role.update); 409 rev_conflict → banner + reloadweb:src/services/role/index.ts:62-86
Deleteonly non-system, role.delete, disabled while user_count > 0, confirm modalroles/index.tsx:77-83

Users pages ​

ElementBehaviourWhere
List /setting/usersclient-side keyword filter over GET /users; role names from GET /roles; store codes from GET /store (page size 1000); state badgeweb: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 checkstaff with no store and not ALL → store_required message, no requestweb:src/services/users/index.ts:86-88
ErrorsapiErrorMessage(e, 'user_error') maps code to a translationweb:src/utils/error.ts:3-7
StoreSelectone-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 routeCore call
GET /api/permissionsGET /permissions
GET/POST /api/rolesGET/POST /roles
PUT/DELETE /api/roles/[id]PUT/DELETE /roles/:id
GET /api/usersGET /users
PUT /api/users/[id]/access, POST /api/users/[id]/{unlock,enable,disable}same under /users
GET/PUT /api/user/meGET /auth/me (with store retry) / PUT /user/me
POST /api/auth/login, POST /api/auth/revokePOST /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) ​

SpecCovers
src/common/guards/auth.guard.spec.tskey 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.tslogin has no app/role check (C5–C7); inactive refused
src/role/role.service.spec.tsseed 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.tssave/version/409 (incl. 20 concurrent runs), unknown keys, audit diff, system role lock, delete rules, list counts, 404
src/role/role.access.spec.tswarm path one Redis lookup / 0 DB reads, Redis failure → DB, deleted role, cold fill, union
src/user/user.admin.service.spec.tsassign + 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.tssystem user seed races, legacy role fix, createUser role_ids
src/common/scope/scope.spec.tsbase-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.tsplan history, plan optimize, DO update by delivery no, script runner async context, sweep + system user as system actor

Web (jest + RTL) ​

SpecCovers
web:src/utils/__tests__/permission.test.tsmenu gating (all, role.read only, hidden item, empty group, no key), store can()
web:src/utils/__tests__/store_scope.test.tsstale/kept/malformed cookie, ALL hint cookie, form default
web:src/apis/server/__tests__/store_header.test.tsheader forwarding, store_forbidden retry, 404/5xx thrown (fail closed)
web:src/__tests__/pages/permission-gate.test.tsxno-access home, <Can>, 403 toast without logout
web:src/__tests__/pages/setting-roles.test.tsxgrouping, system_admin locked, delete visibility, whole-array save with version, conflict reload, unlabelled group, create
web:src/__tests__/pages/setting-users.test.tsxstore_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.tsxpicker defaults/pick/clear/single/ALL search, badge, logout with failing revoke, form default

12. Gaps and known issues ​

#GapEvidenceImpact
G1Web 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.readweb:src/constants/permission-keys.ts:2-25 vs §4 registry; used at web:src/constants/menu.ts:89-137 and the 8 setting pagesEvery 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
G2Web user states do not match core. Web expects active/locked/disabled, core returns active/locked_out/inactive/stagedweb: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-7Unlock 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
G3Design 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
G4D-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 bootsrc/role/role.service.ts:47-52Unedited admin silently gains each new key after deploy (code wins; the commit message states this)
G5Users are unscoped: GET /users and GET /user return every user to any user.read holder, whatever the caller's storessrc/user/user.admin.service.ts:30-33; snapshot user: unscopedDesign §7 "staff see users of their stores" not built
G6Picker and Users store options call GET /store, which is @Scoped({store:'_id'}) and narrowed by X-Store-Idsrc/common/scope/scope.ts:28, web:src/components/StorePicker/index.tsx:28-31, web:src/services/users/index.ts:17-24With 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)
G7Existing DOs have no source_store_id backfill (plans have one)src/plan/plan.module.ts:47-53; none for DOPre-existing DOs are invisible to every store-scoped user; source_store_id has no index
G8Moving a DO to another store does not update its items' store_idsrc/delivery_order_item/delivery_order_item.service.ts:64 only at createItems stay visible under the old store
G9POST /user (driver master page) sets role_ids but no scopessrc/user/user.service.ts:208-212New staff user sees no scoped data until an admin sets stores
G10Actor comes from legacy session.user.role (login snapshot); users with roles outside UserRole or no role get no actorsrc/common/context/actor.ts:33-45Their writes are attributed to the system user
G11Disabled users keep live tokens (TTL 1 year); revoke is not awaited and has no per-user indexsrc/auth/auth.service.ts:124-128,172Accepted for this round (D-578); login round
G12applications.enabled not checked; secrets stored and compared in plain text; Basic auth reads Mongo on every requestsrc/common/guards/auth.guard.ts:60-75, src/auth/auth.service.ts:59-82Design §6.1 not built (login round)
G13Non-default apps holding exactly [system_admin] are reset to [] on every bootsrc/application/application.service.ts:81A future "grant roles to an app" API must remove or mark this (review r2 NOTE)
G14Role delete checks users only, not applicationssrc/role/role.service.ts:165App keeps a dangling id (harmless, contributes no keys)
G15Generic web entity proxy returns only the message, dropping code; core 401 is no longer the "Forbidden resource" string the client redirects onweb:src/pages/api/entity/[entity]/index.ts, web:src/apis/client/request.ts:39-44permission_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-49Self-profile reads go through /auth/me instead
G17_initialSystemUser not awaited before user backfillsrc/user/user.service.ts:35-37Fresh db: the system_admin login user may lack role_ids until a second boot
G18users.role_ids not indexed (countByRoles, holdsRole, _keepOneAdmin scan); plan history scope runs distinct('code') over all visible plans per requestsrc/user/user.schema.ts:40-41, src/plan_history/plan_history.service.ts:31-35Slow on large data
G19Attachments stay unscoped (D-579); driver own-work filtering and partner scope are not built (D-537)snapshot; design §3.6 driver rowDriver sees whatever scopes.store allows
G20/auth/me for a Basic (API key) caller returns user.id: 'undefined'src/auth/auth.me.ts:29Cosmetic
G21Web token cookie maxAge gets expires_in (seconds) where the cookies lib expects ms (pre-existing)web:src/pages/api/auth/login.ts:18Cookie 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 shaweb:src/apis/server/{storage_setting,outbound_event,script_setting}/index.tsPages 404 against this core build regardless of keys

13. How to add the next one ​

A new guarded controller (e.g. trip) ​

  1. Put @Resource('trip') on the class (one resource per controller; use setting.<area> for setting screens). Copy src/vehicle/vehicle.controller.ts.
  2. 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 @Resource on a handler.
  3. Self-service routes any logged-in user needs get @LoggedIn(). Open routes get @Public() (both in src/auth/auth.controller.ts).
  4. Restart. The key appears in GET /permissions and on the Roles page with no web change. An unedited seeded admin gains it at boot. Edited roles do not.
  5. Regenerate web types: npm run permissions:types -- <web>/src/constants/permission-keys.ts (writes the union; replace the hand-written file, which closes G1).
  6. 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 add permission_group_trip to web:src/utils/translate.ts.
  7. Tests: add the key-shape case to src/common/guards/auth.guard.spec.ts and a menu case to web:src/utils/__tests__/permission.test.ts.

A new store-scoped entity ​

  1. Give the schema a scope field (store_id, string, ref: 'store', index: true). Copy src/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).
  2. Decorate every service over that collection with @Scoped({ store: 'store_id' }), sharing one map constant (PLAN_SCOPE, src/plan/plan.schema.ts:180-181). BaseService then scopes every path.
  3. Any query that bypasses BaseService (this.model.*, collection.*, aggregate on another model, sandbox callbacks) must AND withScope(this._scope, filter) or scopeFilter(MAP) and call assignScope(map, data, false) on update bodies (src/delivery_order/delivery_order.service.ts:140-149, src/plan_history/plan_history.service.ts:31-35).
  4. 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).
  5. Add a boot backfill for existing rows (src/plan/plan.service.ts:32-43, called from the module's onModuleInit).
  6. Update the snapshot in src/common/scope/scope.spec.ts (scoped-entity listing) and add list / by-id 404 / foreign-store create 403 / $set operator cases for the entity.
  7. 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).