انتقل إلى المحتوى

Модуль бичих

Бизнес апп нь энэ репод байдаггүй. Өөрийн репод амьдарч, pkg/nexus-ээр өөрийгөө бүртгүүлж, каталогоор ирдэг. Энэ баримт нь backend/pkg/nexus-ийн кодоос уншиж бичигдсэн.

Баримтын төв · Архитектур


Хамгийн бага модуль

main.go дотор гурван мөр:

func main() {
    mymodule.New(db)   // өөрөө nexus.Register дуудна
    host.Run()
}

nexus.Register нь компиляцын үеийн бүртгэл. Платформ маршрут mount хийх, цэс барих, суулгасан апп ард нь код байгаа эсэхийг шалгахдаа энэ бүртгэлээс асууна. Багцын түвшний төлөв байдаг нь зориуд: «энэ програм ямар модуль агуулж байна» гэдэг нь програмын шинж чанар бөгөөд бүртгэлийг конструктор болгоноор дамжуулбал зөвхөн хоёр нь зөрөх боломж л нэмэгдэнэ.

Нэг id-г хоёр модуль нэхэх нь угсралтын алдаа, ажиллагааны нөхцөл биш — хоёулаа нэрээрээ компиляцлагдсан байдаг — тиймээс сүүлийнх нь ялна.

Модуль юу зарлах вэ

type Module interface {
    ID() string
    Name() string
    Version() string
    Dependencies() []Dependency
    Permissions() []PermissionDefinition
    Menus() []MenuDefinition
    RegisterRoutes(r chi.Router, workspaceAuth func(http.Handler) http.Handler)
}

RegisterRoutes нь урьдчилан хүрээлэгдсэн бүлэг биш, үндсэн router-ыг авна. Хамт ирэх middleware нь платформын аль хэдийн гаргасан хоёр шийдвэрийг авч явна: энэ байгууллага аппыг суулгасан эсэх, ба — платформ эрхийн угтварыг нь мэддэг апп бол — дуудагч ийм төрлийн хүсэлт хийж болох эсэх.

Цэсний бүлэг

Апп цэсээ өөрийн нэрлэсэн бүлгүүдэд хуваана. Платформ «Модуль», «Тохиргоо» гэсэн тогтмол хоёр толгой өгөхөө больсон (тэр нь апп бүрт ижил хоёр хайрцаг тулгадаг, ихэнх нь хоосон эсвэл утгагүй нэртэй байв).

Дүрэм:

  1. Бүлэг нь Path, ExternalURL, Frame, ParentID-гүй мөр. Нэрийг дотор нь юу байгаагаар нь өгнө — «Гэрээ», «Загвар», «Тайлан», «Хүсэлт», «Оффис». «Модуль», «Modules», «Цэс», «Бусад» гэх ерөнхий нэр хэрэглэхгүй (manifest-ийн шалгалт «Модуль/Modules»-ийг татгалзана).
  2. Дэлгэц бүр ParentID-ээр өөрийн аппын бүлгийг нэрлэнэ. Өөр аппын бүлэг рүү заасан, эсвэл бүлэг нэрлээгүй мөр аппын нэртэй нэг бүлэгт ордог — энэ нь хуучин manifest-ийг эвдэхгүй байх нөөц, зорилго биш.
  3. Монгол нэрийг labels-ээр өгнө ({"mn": "Гэрээ", …}); label нь англи.
  4. Нэг бүлэгтэй апп ч бүлгээ нэрлэнэ; нэг дэлгэцтэй бол бүлэг нь аппын нэр байж болно.
  5. MenuDefinition.Group (nexus.MenuGroupSettings) хуучирсан, үл тоомсорлоно.
"menus": [
  { "id": "contracts", "label": "Contracts", "labels": { "mn": "Гэрээ" }, "icon": "file-text", "order": 10 },
  { "id": "setup", "label": "Setup", "labels": { "mn": "Тохируулга" }, "icon": "settings", "order": 20 },
  { "id": "contract_list", "parent_id": "contracts", "label": "All contracts", "labels": { "mn": "Бүх гэрээ" }, "path": "/module/contracts", "icon": "list", "order": 10 },
  { "id": "contract_templates", "parent_id": "setup", "label": "Templates", "labels": { "mn": "Загвар" }, "path": "/module/contracts/templates", "icon": "copy", "order": 10 }
]

Платформ бүлгийн id-д аппын slug-ийг угтвар болгож (contracts:setup) хоёр апп ижил id-тэй бүлэгтэй байсан ч салгана (internal/workspace/menu, grouped). Консолын ConsoleMenus яг ийм дүрмээр уншигддаг.

Эрхийг платформд даалгах

type AccessPolicy interface {
    MenuPermission() string
    RoutePermissionPrefix() string
}

Хэрэгжүүлэх нь заавал биш. Хэрэгжүүлээгүй модуль зөвхөн «суулгасан эсэх»-ээр хамгаалагдана, эрхээ өөрөө шалгах үүрэгтэй.

Хоосон мөр нь бодит хариулт, орхигдсон зүйл биш: «платформ үүнийг бүү хаа» гэсэн үг. Үүнийг чангаар хэлэх нь чухал — өмнө нь чимээгүй байх нь «эрх шаардлагагүй» ба «энэ модуль өөрөө шалгадаг» хоёрыг хоёуланг нь илэрхийлдэг байсан бөгөөд кодод тэднийг ялгах арга байгаагүй.

RoutePermissionPrefix нь GET/HEAD-д prefix.read, өөрчилж чадах бүхэнд prefix.manage шаардана. Дүрэм нь үйл үгээр илэрхийлэгдэхээс нарийн бол — жишээ нь өргөдөл гаргагчийн харьяалагдах нэгжээс хамаарах зөвшөөрлийн алхам — хоосон үлдээгээд өөрөө шалгана.

Энэ нь өмнө нь платформ дотор апп бүрийг нэрээр нь жагсаасан хоёр switch байсны оронд бий болсон. Өөр репод амьдардаг модуль энэ репо доторх switch-д өөрийгөө нэмж чадахгүй, тэгээд юу болдог вэ гэвэл compile алдаа биш — чимээгүй сулрал: салгагдсан апп ажилласаар, хажуугийн самбарт харагдсаар, харин хаагдахаа болино. Refactor-ийн үеэр чимээгүй алга болдог эрхийн шалгалт бол энэ төрлийн алдааны хамгийн муу хэлбэр.

Операторын консолд дэлгэцтэй байх

type ConsoleApp interface {
    ConsoleMenus() []MenuDefinition
    RegisterConsoleRoutes(r chi.Router, gate func(http.Handler) http.Handler)
    ConsoleCapability() string
}

Сонголтот. Хэрэгжүүлсэн модуль операторын консолын App Store-д гарч, оператор суулгамагц rail-д tile болно; API нь /api/platform/v1/apps/{id} дор, gate-ээр (нэвтэрсэн оператор, апп суусан-идэвхтэй, ConsoleCapability) хүрээлэгдэнэ. Manifest-д "surfaces": ["operator"] (эсвэл хоёулаа) зарлана; гадаад (external) апп консолд орохгүй.

ConsoleMenus: Path-гүй тодорхойлолт нь бүлэг, ParentID-тэй нь тухайн бүлгийн зүйл. Path нь /cp/apps/<slug>/… (<slug> = nexus.AppSlug(id)) — консол тэр замыг модулийн өөрийн /r/cp/<slug>/… хуудсаар frame хийж зурна (nexus.ConsolePageServer, доорх Модулийн өөрийн дэлгэц). Icon нь lib/icons-ийн нэр.

Консол дундуур ирэх бичих хүсэлт бүрийг gate өөрөө operator.app.write action-аар аудит мөр болгож бичдэг (TargetID нь апп-ын id, X-Reason header нь шалтгаан); модуль internal/workspace/audit, тэр байтугай operator plane-ийг ч import хийж чадахгүй тул аудит мөрийг өөрөө бичих шаардлагагүй бөгөөд боломжгүй ч юм.

Консол өөрөө хоёр суурь апптай: «Платформ» (профайл, эрүүл мэнд, хүмүүс, байгууллагууд, аудит) ба «Тохиргоо» (тохиргоо, операторууд, App Store; rail-ийн ёроолд). Хоёулаа заавал суусан, авч хаяшгүй. «Үйл ажиллагаа» (Ops) нь core апп — каталогоос биш цөмөөс ирдэг ч /cp/apps-аас суулгаж/хасдаг; хасахад зөвхөн tile нь алга болж, API маршрутууд нь операторын эрхийн ард үлдэнэ. Каталогийн апп консолд гарахын тулд manifest-даа "surfaces": ["operator"] зарлана.

Цөмийн консол апп бүр nexus.RegisterConsoleBase-ээр бүртгэгддэг (backend/pkg/nexus/console.go, internal/operator/apps/builtin.go). Түгээлт аль нэгийг нь цөмд гар хүрэлгүй өөрчилж болно: host.Options.Modules дотроос ижил tile id-тай ConsoleBaseApp бүртгэвэл (сүүлд бүртгэсэн нь давамгайлна) tile, цэс, /api/platform/v1/apps/{id} доорх API нь солигдоно. nexus.StaticConsoleApp нь зөвхөн tile-тай апп; цөмийнхийг nexus.ConsoleBase(id)-ээр авч, хуулбарыг нь засаад буцааж бүртгэж болно.

Байгууллагын ажлын муж яг ийм загвартай (internal/workspace/menu, internal/workspace/coreapps). Хоёр built-in: «Профайл» (nexus.profile — хянах самбар, миний данс, харагдац) ба «Байгууллагын профайл» (nexus.organisation_profile — хянах самбар /organisation/dashboard, байгууллагын мэдээлэл, нийтэлсэн үйлчилгээ, App Store, суулгасан аппууд). Аль нь ч app_installations мөр шаарддаггүй. «Хандалт» (nexus.access) нь core апп: coreapps.Core-д зарлагдсан модуль, installer-ийн GetCatalog() каталогийн урд нь санал болгодог, EnsureDefaultApps tenant бүрд анхнаасаа суулгадаг, App Store-оос идэвхгүй болгож/устгаж болно. Хуучин хатуу бичсэн «платформ» хажуугийн цэс байхгүй: ямар ч суулгасан апп эзэмшдэггүй замд бүрхүүл Байгууллагын профайлын цэсийг харуулна, manifest-даа chrome: true зарласан апп-ын дэлгэцүүд тэр цэс дотор нэгддэг.

Модулийн өөрийн дэлгэц (/r/)

Distribution цөмийн Next.js-д хуудас нэмж чадахгүй. Тиймээс compiled модуль дэлгэцээ өөрөө, shell-тэй ижил origin дээр HTML-ээр үйлчилнэ; бүрхүүл (цэс, tenant сэлгэгч, native shell) хэвээр үлдэж, хуудсыг same-origin iframe-д зурна. Session cookie ижил origin-оор явдаг тул нэмэлт SSO хэрэггүй.

type PageServer interface {        // /r/<slug>/     — workspace gate
    RegisterPages(r chi.Router, gate func(http.Handler) http.Handler)
}
type PersonPageServer interface {  // /r/me/<slug>/  — person gate
    RegisterPersonPages(r chi.Router, personGate func(http.Handler) http.Handler)
}
type ConsolePageServer interface { // /r/cp/<slug>/  — консолын gate
    RegisterConsolePages(r chi.Router, gate func(http.Handler) http.Handler)
}

<slug> нь nexus.AppSlug(id) — id-ийн сүүлийн хэсэг, _ нь - (mn.eproperty.units → units). r аль хэдийн тэр угтвар дээр байна; gate-ийг платформ бүх mount-д өөрөө тавьдаг (модуль мартсан ч хаалттай), мөн RegisterRoutes-ийнх шиг модульд дамжуулна: session-гүй бол 401, апп суугаагүй бол 403, RoutePermissionPrefix зарласан бол GET-д prefix.read. me, cp slug нь нөөцлөгдсөн (эхлэх үед panic).

Цэсэнд MenuDefinition.Frame-ээр холбоно: Frame: "/r/units/list". Платформ түүнийг ValidateFrame-ээр шалгаад (өөрийн /r/<slug>/ дор, scheme, ?, #, //, .. байхгүй) Path-ийг бүрхүүлийн frame дэлгэц /module/frame/units/list болгоно. Хувь хүний (personal) дуудагчид ижил мөр /module/frame/me/units/list → /r/me/units/list руу заана. Модуль тухайн гадаргуунд хуудас үйлчилдэггүй бол, эсвэл Frame буруу бол мөр цэснээс хасагдана. Frame manifest-ээс уншигддаггүй (json:"-"): гадаад апп энэ замыг авахгүй, тэдэнд mini app бий.

//go:embed pages
var pages embed.FS
var tpl = template.Must(template.ParseFS(pages, "pages/*.html"))

func (m *Module) Menus() []nexus.MenuDefinition {
    return []nexus.MenuDefinition{{ID: "units_list", Label: "Нэгжүүд", Icon: "building", Frame: "/r/units/list"}}
}

func (m *Module) RegisterPages(r chi.Router, gate func(http.Handler) http.Handler) {
    static, _ := fs.Sub(pages, "pages")
    r.Handle("/static/*", http.StripPrefix("/r/units", http.FileServerFS(static)))
    r.Get("/list", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        _ = tpl.ExecuteTemplate(w, "list.html", map[string]string{"Workspace": nexus.WorkspaceOf(r.Context())})
    })
}
<!-- pages/list.html -->
<!doctype html><meta charset="utf-8"><title>Нэгжүүд</title>
<p>Ажлын муж: {{.Workspace}}</p>
<button id="save">Хадгалах</button>
<script src="/r/units/static/list.js"></script>
// pages/static/list.js — inline script CSP-д хориотой ('self' л)
document.getElementById("save").onclick = () =>
  fetch("/api/v1/units/items", {
    method: "POST", credentials: "same-origin",
    headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "A-101" }),
  });
addEventListener("message", (e) => {
  if (e.origin !== location.origin || e.data?.type !== "nexus:context") return;
  document.documentElement.classList.toggle("dark", e.data.theme === "dark");
});
  • Толгой: зөвхөн /r/ дор X-Frame-Options: SAMEORIGIN ба CSP frame-ancestors 'self'; бусад бүх зам DENY хэвээр. CSP нь script-src 'self' — script-ээ файлаар, үнэмлэхүй замаар (/r/<slug>/…).
  • CSRF: frame доторх fetch нь same-origin (Sec-Fetch-Site: same-origin) тул cookie-тэй бичих хүсэлт CSRFMiddleware-ийг давна; өөр origin-оос ирсэн нь 403 хэвээр.
  • Контекст: URL-д ?tenant=&lang=&theme=, дараа нь nexus:context postMessage — SHELL_CONTRACT. Tenant-ийг өөрөө session-оос (nexus.WorkspaceOf) уншина; query нь мэдээлэл.
  • Ingress: /r/-ийг API руу (/api/-тай адил, add_header-гүй) дамжуулна. Консолын host дээр /r/cp/.
  • Модулийн хуудас бүрхүүлтэй ижил origin дээр ажилладаг тул бүрхүүлтэй адил итгэлтэй код: compiled модуль л үүнийг авна.

Гурван түвшний App Store: console / workspace / person

App Store нэг биш, гурав: консол өөрийн (/cp/apps, дээрх хэсэг), байгууллагын өөрийн (/apps) ба одоо хүн өөрийнхөө өөрийн (/me/apps). Аль нь ч бусдынхыг дуурайдаггүй — гурвуулаа catalog.CatalogApp-аас ирдэг ижил каталогийг өөр өөр шүүлтүүрээр харна.

Manifest "surfaces": [...]-ээр аль хэлбэрт нь өөрийгөө санал болгохоо хэлнэ: "console", "workspace", "person" — эдгээрийн хослол ч болно, нэг апп зэрэг гурван дэлгэцтэй байж болно. "operator" ба "tenant" нь "console"/"workspace"-ийн хуучин нэр, alias хэлбэрээр хэвээрээ хүлээн зөвшөөрөгдөнө (normalizeSurface); хоосон surfaces бол ["workspace"] гэсэн үг — энэ талбар байхаас өмнө бичигдсэн manifest бүр урьдын адилаараа л ажиллана. Зөвхөн модуль консолын гадаргуу нэхэж болно; гадаад (external) апп консолд орохгүй (Операторын консолд дэлгэцтэй байх-ийг үз).

Апп нэг л удаа catalog.json-д бичигддэг ч гурван дэлгэц дээр гурван өөр шийдвэрээр гарч ирнэ:

  • Консол (operator/apps/store.go): HasSurface(catalog.SurfaceConsole).
  • Байгууллага (workspace/appinstall/store_handlers.go, HandleListStoreApps): HasSurface(catalog.SurfaceWorkspace).
  • Хувь хүн (workspace/appinstall/person_handlers.go, person_store.go-гийн personApp): HasSurface(catalog.SurfacePerson); таарахгүй бол суулгах/идэвхжүүлэх/устгах/түүх — бүх бичих зам ErrNotPersonApp → 404 буцаана, каталогт байхгүй slug-тай адилхан. 2026-10-21-ээс хойш хүнд дэлгүүрийн дэлгэц (/me/apps) байхгүй: person-surface аппыг дистрибуц DefaultPersonApps-аар хүн бүрд суулгана (power-grid-mn-ийн PowerGrid App). API (/person/apps*) ба person gate хэвээр.

Хэдийгээр каталог нэг ч гурван дэлгэц өөр өөр эрхийн загвартай:

  • Консолын суулгац бүр (operator.console_apps) — платформын хэмжээнд нэг.
  • Байгууллагын суулгац (workspace.app_installations) — tenant_id-аар тусгаарлагдсан.
  • Хувийн суулгац (workspace.person_app_installations, migration 00112) — tenant_id огт байхгүй, user_id-аар (PRIMARY KEY (user_id, app_id)) тусгаарлагдсан: хүн байгууллагад орсон ч, ороогүй ч адилхан ажиллана. Row-level security нь 00111-ийн person_isolation хэлбэрийг дагана — user_id = app.current_user — байгууллагын мөр рүү огт хамаарахгүй. RLS нь 00128-аас хойш FORCE (хүснэтийн эзэн роль ч бодлогод захирагдана). Хэрэглэгч бүрийн хувийн апп нь өөрийг нь дагаж явдаг, аль ч байгууллага түүнийг харах, удирдах эрхгүй.

Хувь хүний өөрийн route (nexus.PersonApp). Compiled модуль RegisterPersonRoutes(r, personGate)-ийг хэрэгжүүлбэл платформ RegisterRoutes-ийн хажууд түүнийг mount хийнэ (workspace/app/routes.go). personGate (appinstall.PersonGateMiddleware) нь: session-гүй бол 401; модулийн kill switch асаалттай бол 503; оператор тухайн хүний өмнөөс орсон (impersonation) бол зөвхөн GET/HEAD; хүн аппыг өөртөө суулгаагүй бол 403. Нэвтрүүлэхдээ хүсэлтийг тухайн хүнд л хамааруулна: session байгууллагатай байсан ч workspace, AllowedWorkspaceIDs, IsAdmin хасагдаж nexus.IsPersonScoped үнэн болно — dbguard tenant ролийг tenant-гүйгээр холбоно. Тиймээс түгээх компанийн ажилтан өөрийн тоолуурыг компани биш, иргэн байдлаар л харна. nexus.PersonOf(ctx) нь eID-ийн илгээсэн регистрийг (registry.own_eid_identity, том үсгээр) буцаана; eID-ээр батлагдаагүй данс бол nexus.ErrNoEID.

Distribution өөрийн person app-ийг хүн бүрт анхдагчаар суулгахыг host.Options.DefaultPersonApps-аар заана (цөмийн coreapps.PersonDefaultIDs-ийн хажууд). Хүний цэс эсвэл store-ийг анх уншихад суугдана. Хүн устгавал мөр нь enabled = FALSE-ээр үлдэх тул буцаж суугдахгүй.

Цэсний харагдац RBAC-аар шийдэгддэг, суулгацаар биш. Апп хоёр (эсвэл гурван) гадаргуутай байж, зөвхөн нэг талд нь цэсэндээ гарч болно — үүнийг шийддэг зүйл нь nexus.AccessPolicy.MenuPermission() ба HandleMenus-ийн дараах шүүлтүүр (appReadPermission): байгууллагын дуудагчийн хувьд цэсний мөр бүрийг холбогдох MenuPermission-той харьцуулж, тухайн гишүүн эрхгүй бол мөрийг хасдаг. Хувийн (tenantID == "") дуудагчийн хувьд буцаагаар: ямар ч байгууллагын гишүүнчлэлгүй тул MenuPermission зарласан модулийг menu.GetTenantMenus бүхэлд нь алгасдаг — зөвхөн ямар ч байгууллагын эрх шаарддаггүй, person гадаргуутай, идэвхжсэн апп л хувь хүний цэсэнд гарна. Гадаад (external) апп үл хамаарна: хувийн цэсэнд байгаа нь хүн өөрөө суулгасных, manifest-ийн .read нь байгууллагын эрх тул хувийн дуудагчид шүүлт болохгүй (2026-10-01 хүртэл ийм аппууд суулгасан хүнээсээ нуугддаг байв).

Хуучин алдаа: GetTenantMenus урьд нь built-in Профайл болон Байгууллагын профайл хоёрыг зэрэг залгадаг байсан тул байгууллагын ажлын мужид «Профайл» tile хоёр удаа (нэг нь байгууллагынх, нөгөө нь хувийн) харагддаг байв. Одоо tenantID-аар ялгаж, хувийн дуудагч зөвхөн Профайл, байгууллагын дуудагч зөвхөн Байгууллагын профайл авдаг.

Аппын дэлгэц цөмийн бүрхүүлд

Бүрхүүл бол бүх distribution-ий нэг образ (web), тиймээс distribution-ий аппын дэлгэц ч энд, frontend/app/module/<slug>/ дор амьдарна — kiosk, eID-ийнх шиг. Статик сегмент app/module/[app]/[feature]-ийн «удахгүй» хуудаснаас давуу тул дэлгэцгүй апп хэвээр тэр хуудас руу унана. Хэвшсэн хэлбэр (жишээ нь Gerege Wallet, slug bank; Mini апп платформ, slug miniapp — цэсний замаас гадна apps/[id] гэх мэт дэд route-тэй байж болно):

Файл Юу
app/module/<slug>/layout.tsx Аппын толийг (lib/i18n/apps/<slug>.ts) import хийж бүртгэнэ; дэлгэцүүдийг BrandAccent-аар ороож суулгацын өнгөөр зурна
app/module/<slug>/template.tsx PageTransition — аппын route хооронд шилжих бүрд дахин mount болж нэг ижил байдлаар орж ирнэ
app/module/<slug>/page.tsx Хүний (person) дэлгэц, хэрэв апп person гадаргуутай бол
app/module/<slug>/<feature>/page.tsx Модулийн Menus()-ийн Path бүрт нэг ажлын мужийн дэлгэц
lib/api/<slug>.ts Модулийн route-уудын хэлбэр; lib/api.ts-ээр биш шууд import хийнэ (дэлгэцгүй суулгацын chunk-д орохгүй)
lib/i18n/addons/<slug>.ts mn ба en эх (Орчуулга)
lib/personScreens.ts Хүний дэлгэцтэй бол slug → зам; хүний самбар («Миний аппууд») тэндээс холбоос гаргана

Хүн аппдаа хаанаас хүрэх вэ. Гэрийн цэс MenuPermission зарласан модулийг бүхэлд нь алгасдаг (дээрх хэсэг), арын албатай апп түүнийг арын албандаа зарладаг. Тиймээс хүний орох хаалга нь /my/dashboard-ийн «Миний аппууд»: GET /person/apps суусан, идэвхтэй гэж хэлсэн, lib/personScreens.ts-д замтай апп л tile болно. Хүнд суулгаагүй бол person gate 403 буцаана; дэлгэц нь тэр үед POST /person/apps/<slug>/install-ыг санал болгож болно, гэхдээ ердийн зам нь distribution-ий host.Options.DefaultPersonApps.

Хүний гэр. /my/dashboard нь санхүүгийн тоймын бүтэцтэй (components/home): дээд хэсгийг юу дүүргэхийг GET /person/apps шийднэ. Хүн bank-ийг барьж байвал хэтэвчийн өөрийн картууд (components/bank-ийн MoneyCards, useWallet, WalletSheets — давхардуулахгүй, next/dynamic-аар хэрэгтэй үед л татагдана), miniapp-ийг барьж байвал mini апп-ын мөр; үгүй бол бүртгэлийн картууд, тэр аппын route руу ганц ч хүсэлтгүй. Апп 503 буцаавал тайван «идэвхгүй» мөр. Өөр апп гэрт карт нэмэх бол ийм л байдлаар: эхлээд holds(apps, slug), дараа нь аппын chunk. Нийтлэг картын хэлбэр (IconChip, PillAction, StatCard) components/overview/parts.tsx-д.

Өнгө. BrandAccent (components/BrandAccent.tsx) нь BRAND_THEME_COLOR-ийг accent токен болгодог; hover, soft, ring, текстийн өнгийг (app/app-screens.css) түүнээс гаргана. Хүн эсвэл суулгац accent-ийг тодорхой сонгосон бол (data-accent) тэр нь давамгайлна. Portal-оор <body> руу гардаг sheet useBrandAccentScope()-ээр өнгөө авч явна.

Хөдөлгөөн. components/motion — пүршний муруйг native клиентийн параметрээс (response / damping) CSS linear() болгон түүвэрлэсэн: appear(i) (дараалсан гарч ирэлт), PageTransition, useTabDirection (таб аль талаас орох), StackedSheet (доорх sheet-ээ жижигрүүлж, түлхэж, бүдгэрүүлдэг давхарга; утсан дээр доороос, sm-ээс баруунаас), useSheetHost (анхны sheet-ийн доорх хуудас ухрах), motion-press класс. prefers-reduced-motion үед сан хөдөлгөөнийг тасалдаг, CSS үлдэх байрлалын шилжилтийг хасдаг.

Модулийн өөрийн schema

Модуль өөрийн migration-ыг embed хийж, конструктораасаа өгнө:

//go:embed migrations/*.sql
var migrations embed.FS

Яагаад ийм болсныг мэдэх нь чухал: өмнө нь хүснэгт үүсгэх цорын ганц газар нь платформын migration хавтас байсан. Үр дүнд нь суулгац бүр энд хэзээ нэгэн цагт бичигдсэн бүх аппын schema-г авч явдаг байв — худалдаа, POS, төрийн үйлчилгээний урсгал зэрэг энэ репод байхаа больсон аппуудынх ч дотроо.

Модулийн migration нь суулгах үед л ажилладаг. Апп «суулгагдсан» атлаа хүснэгтгүй байх боломжтой — тэр нь суулгалт нь migration-аас өмнө бичигдсэн гэсэн үг.

Хүнд хамаарах хүснэгт (person path)

nexus.PersonApp-ийн route дээр dbguard tenant ролийг байгууллагагүйгээр холбож, app.current_user-д дуудагчийн id-г тавьдаг. Тиймээс хүний өөрийн мөрийг user_id-аар тусгаарлана — 00102-ийн person_isolation хэлбэр, FORCE RLS ба 00115-ийн platform_path-тай (session-гүй зам, sweep):

-- +goose Up
CREATE TABLE myapp.my_meters (
    id      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id uuid NOT NULL REFERENCES registry.users (id),
    label   text NOT NULL
);
ALTER TABLE myapp.my_meters ENABLE ROW LEVEL SECURITY;
ALTER TABLE myapp.my_meters FORCE ROW LEVEL SECURITY;
CREATE POLICY person_isolation ON myapp.my_meters TO gerege_nexus_tenant
    USING (user_id = NULLIF(current_setting('app.current_user', true), '')::uuid)
    WITH CHECK (user_id = NULLIF(current_setting('app.current_user', true), '')::uuid);
CREATE POLICY platform_path ON myapp.my_meters TO gerege_nexus_api USING (true) WITH CHECK (true);
GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.my_meters TO gerege_nexus_tenant, gerege_nexus_api;
-- +goose Down
DROP TABLE myapp.my_meters;

NULLIF(…, '') нь нэвтрээгүй холболтыг (хоосон утга) NULL болгож юу ч таалгүй болгоно. Байгууллагын route дээр ч app.current_user тавигддаг тул энэ бодлого тэнд ч зөвхөн тухайн хүний мөрийг харуулна. nexustest.AsPerson-оор шалгана.

Нэвтрээгүй нийтийн route (nexus.PublicRead)

Session-гүй route (/verify/{ref}, гэрчилгээ шалгах, нийтийн бүртгэл) dbguard-аар platform path буюу login role-оор ажилладаг — тэр нь platform_path-аар бүх байгууллагын мөрийг уншдаг. Нэг мартсан WHERE бүх хүснэгтийг ил гаргахаас сэргийлж, нийтэд гаргах зүйлээ view болгоод зөвхөн түүнийг gerege_nexus_public (migration 00151) роль руу нээнэ:

CREATE VIEW myapp.public_certificates AS
    SELECT ref, holder_name, issued_on FROM myapp.certificates WHERE revoked_at IS NULL;
GRANT USAGE ON SCHEMA myapp TO gerege_nexus_public;
GRANT SELECT ON myapp.public_certificates TO gerege_nexus_public;
r.Get("/api/v1/myapp/verify/{ref}", func(w http.ResponseWriter, r *http.Request) {
    var holder string
    err := nexus.PublicRead(r.Context(), m.db, func(q nexus.PublicQuerier) error {
        return q.QueryRow(r.Context(), `SELECT holder_name FROM myapp.public_certificates WHERE ref = $1`,
            chi.URLParam(r, "ref")).Scan(&holder)
    })
    // …
})

PublicRead нь READ ONLY транзакц нээж gerege_nexus_public руу SET LOCAL ROLE хийнэ, app.* холболтыг хоосолно. Роль нь ямар ч хүснэгт, схемийн эрхгүй тул GRANT хийгээгүй хүснэгт «permission denied» өгнө; бичих нь роль ба read-only транзакц хоёр давхаргаар хаалттай (андуурч INSERT олгосон ч бичихгүй). View нь эзэмшигчийнхээ эрхээр уншдаг тул view-ийн WHERE нь нийтэд юу гарахыг шийднэ. Энэ нь модулийн өөрийн кодын эсрэг хамгаалалт биш, алдааны эсрэг давхарга: fn дотор RESET ROLE хийсэн SQL login role руу буцна (read-only хэвээр).

Платформоос юу авах вэ

type Platform interface {
    DB() DB
    Permissions() PermissionStore
}

DB нь байгууллагад уясан холбоо: dbguard нь холболт бүрийг дуудагчийн байгууллагад уядаг тул модулийн query нь өөр байгууллагын мөр харах боломжгүй (Архитектур).

BeginTx нь pgx.ReadOnly-г дэмждэг: «энэ бичихгүй» гэдгээ компиляторт төдийгүй өгөгдлийн санд хэлж чаддаг байх ёстой.

Цаг хуваарьт ажил (nexus.JobRunner)

Модуль үечилсэн ажлаа зарлавал платформ ажиллуулна — олон replica-тай ч нэг үед нэг л удаа:

func (m *Module) Jobs() []nexus.Job {
    return []nexus.Job{{
        Name:    "release-holds",       // түлхүүр нь "<app id>/release-holds"
        Every:   15 * time.Minute,      // хамгийн багадаа 1 минут
        Timeout: 2 * time.Minute,       // анхдагч 1 минут, Every-ийн 9/10-оос хэтрэхгүй
        Run: func(ctx context.Context) error {
            // ctx нь platform path (tenant-гүй): байгууллага бүрийг өөрөө давтаж,
            // nexus.WithWorkspaceID(ctx, id)-аар хамрах хүрээгээ тогтооно.
            return m.releaseExpiredHolds(ctx)
        },
    }}
}
  • Tick бүрд: pinned холболт дээр pg_try_advisory_lock (зэрэг ажиллахгүй), дотор нь operator.module_jobs-д энэ үеийн claim (өөр мөчид tick хийсэн replica дахин ажиллуулахгүй). Lock-гүй replica юу ч хийхгүй.
  • module.<app id>.disabled (модуль бүхэлдээ) эсвэл job.<app id>.<name>.disabled (энэ ажил л) flag асаалттай бол алгасна.
  • Timeout-оор ctx цуцлагдана, panic барина, алдааг slog-оор бичнэ; дараагийн үед дахин ажиллана. Метрик: module_job_runs_total{job,result}, module_job_duration_seconds{job}. Сүүлийн төлөв /cp/ops/jobs-д.
  • Жагсаалт буруу (нэр давхардсан, Every < 1 минут, Run байхгүй) бол тухайн модулийн ажил нэг нь ч эхлэхгүй, лог-д шалтгаан бичигдэнэ.
  • StartHousekeeping(ctx) хэвээр ажиллана — гэхдээ тэр replica бүр дээр ажилладаг.

Модулийг тестлэх (pkg/nexustest)

Модулийн хүснэгт, бодлого, handler-ийг бодит Postgres дээр, dbguard-ын холбодог роль, app.* тохиргоотойгоор шалгах хэрэгсэл. Өмнө нь distribution бүр (power-grid-mn-ийн sessionAs/appFixture) үүнийг гараар бичдэг байсан.

func TestNotesAreIsolated(t *testing.T) {
    pool := nexustest.Open(t)                    // TEST_DATABASE_URL, байхгүй бол t.Skip
    nexustest.Migrate(t, map[string]fs.FS{       // цөм + модулийн migration, процесст нэг удаа
        notes.ID: notes.Migrations(),
    })
    tx := nexustest.Tx(t, pool)                  // тест дуусахад rollback

    ws, alice := nexustest.NewWorkspace(t, tx), nexustest.NewUser(t, tx)
    nexustest.AddMember(t, tx, ws, alice, "notes.read") // membership + role + эрх, бодит хүснэгтээр

    m := notes.New(nexus.NewPlatform(nexustest.DB(tx), nexustest.Permissions(tx)))
    h := nexustest.Handler(t, tx, m, nexus.UserClaims{UserID: alice, WorkspaceID: ws})
    rec := httptest.NewRecorder()
    h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/notes", nil))
    // …эсвэл store-ийг шууд: nexustest.AsTenant(t, tx, ws, alice) дараа нь query.
}
Open(t), Migrate(t, modules), Tx(t, pool) Холболт, pkg/migrate.Run-аар migration, rollback хийгдэх транзакц
DB(tx) Транзакцыг nexus.DB болгоно; модулийн Begin/BeginTx savepoint болно
AsTenant(t, tx, ws, user), AsPerson(t, tx, user), AsPlatform(t, tx) dbguard-ын гурван холболт (SET LOCAL): tenant роль+байгууллага, tenant роль байгууллагагүй, login роль (platform_path)
NewUser, NewWorkspace, AddMember registry.users, registry.tenants, workspace.memberships/roles/role_permissions; одоогийн холболтыг өөрчлөхгүй
Permissions(tx) Платформын эрхийн хайлт (cache-гүй)
Handler(t, tx, m, claims) RegisterRoutes-ийг workspace gate-тэй (401, RoutePermissionPrefix, AsTenant), PersonApp-ийг person gate-тэй (workspace хасна, AsPerson) mount хийнэ; gate-гүй route AsPlatform
Signer, EID, People, Provide[T](t, impl) Хуурамч nexus.Signer, nexus.EIDSigner, nexus.PersonIdentities; Provide тест дуусахад өмнөхийг сэргээнэ

Тестийн холболт роль руу SET ROLE хийж чадах (CI-д superuser) байх ёстой. Суулгацын шалгалт, kill switch, session, CSRF-ийг Handler давтахгүй — модулийн тест бол аппыг суулгасан байгууллагын тест. Жишээ бүтэн модуль: backend/pkg/nexustest/nexustest_test.go (tenant A нь B-ийн мөрийг RLS-ээр харахгүйг HTTP-ээр батална).

Илүү өргөн боломжууд

Платформын өгдөг бусад зүйлс — бүгд pkg/nexus-д:

nexus.Audit Audit мөр бичих. Модуль internal/workspace/audit-ыг import хийж чадахгүй тул энэ зам байдаг
nexus.RegisterReport Тайлан зарлах; тайлангийн хөдөлгүүр түүнийг ажиллуулна
nexus.RequirePermission Эрхийн middleware
nexus.PublicRead Нэвтрээгүй route-ийн зөвхөн уншдаг холболт (gerege_nexus_public)
nexus.RateLimit, nexus.QuotaGate Хязгаарлалт
nexus.Documents, nexus.RecordSigned Гарын үсгийн зам
nexus.Signer, nexus.OrganisationSigner eID гарын үсэг — хувь хүний, байгууллагын нэрийн өмнөөс (SIGNING)
nexus.Files Файл, нотолгоо хадгалах (доор)
nexus.EID, nexus.DAN Танилтын зам
nexus.People, nexus.Directory Хүн, лавлах
nexus.Provide / nexus.Capability Чадварыг өгөх, авах
nexus.Secrets Нууц утга
nexus.Notify Хүнд мэдэгдэл (хонх + и-мэйл) — доор

Хүнд мэдэгдэл: nexus.Notify

Модуль хүнд «гэрээ гарын үсэг хүлээж байна», «УБЕГ хариу өглөө» гэж хэлэх нэг зам. Мэдэгдэл нь ажлын мужийн толгой дээрх хонхонд гарна; суулгац SMTP relay-тэй (REPORT_SMTP_URL, REPORT_MAIL_FROM — тайлангийнхтай нэг) бөгөөд хүн баталгаажсан хаягтай, энэ Kind-ийг дуугүй болгоогүй бол нэг удаа мэйлээр ч очно.

err := nexus.Notify(ctx, nexus.Notification{
    UserID: applicantAccountID,
    App:    "io.gerege.property",          // модулийн id
    Key:    "lease-42-expires-2026-11",    // үйл явдлын нэр: давтан дуудалт no-op
    Kind:   "lease.expiring",              // хүн мэйлийг үүгээр хаана
    Title:  map[string]string{"mn": "Түрээс дуусах гэж байна", "en": "Your lease is ending"},
    Body:   map[string]string{"mn": "42 дугаар гэрээ 11-р сард дуусна."},
    Link:   "/apps/property/leases/42",    // зөвхөн энэ сайт доторх зам
})
  • Идемпотент: (UserID, App, Key) нэг мессеж. Ижил гурвалаар хоёр дахь дуудалт nil буцааж юу ч хийхгүй — шинэчлэхгүй, дахин мэйл явуулахгүй. Түлхүүрийг үйл явдлаар нэрлэ, санамсаргүй id биш.
  • Хэнд: байгууллагад уясан хүсэлтээс хэнд ч (илгээгч байгууллага нь session-оос бичигдэнэ); байгууллагагүй person замаас зөвхөн өөртөө.
  • Шалгалт: Title/Body-д "mn" заавал, бусад нь nexus.SupportedLocales; Link нь /-ээр эхэлсэн зам (//host, /\host, https://… татгалзана). Notify эдгээрийг хадгалахаас өмнө шалгаж ErrNotificationInvalid буцаана.
  • Хүний API: GET /api/v1/me/notifications (шинэ нь эхэнд, ?before=<id>), GET …/unread-count, POST …/{id}/read, POST …/read-all, GET|PUT|DELETE …/email-mutes/{kind} (төрлөөр мэйл хаах / нээх).
  • Push одоогоор байхгүй: утасны token бүртгэгддэг ч APNs/FCM клиент платформд алга. Тэр нь тусдаа ажил бөгөөд эдгээр мөрийг л уншина — модулийн дуудалт өөрчлөгдөхгүй.

Байгууллагын нэрийн өмнөөс гарын үсэг — платформ эрх, регистр, eID-ийн төлөөллийг өөрөө шалгаж, audit мөр бичээд ceremony эхлүүлнэ:

signer, err := nexus.Capability[nexus.OrganisationSigner]()
if err != nil { /* суулгацад байхгүй */ }
ws, _ := nexus.WorkspaceID(r.Context())
session, err := signer.SignForOrganisation(r.Context(), nexus.OrgSignatureRequest{
    WorkspaceID:  ws,
    Permission:   "property.sign", // модулийн зарласан эрх
    DigestHex:    hex.EncodeToString(sum[:]), // эсвэл PDF: pdfBytes
    DisplayText:  "Гэрээ №42 батлах",
    DocumentName: "geree-42",
})
switch {
case errors.Is(err, nexus.ErrForbidden), errors.Is(err, nexus.ErrNotRepresentative):
    nexus.Error(w, http.StatusForbidden, err.Error()); return
case err != nil:
    // ErrNoOrganisationRegister, ErrNoSignerIdentity, ErrSigningUnavailable …
}
// дараа нь Signer.PollSignature(ctx, session.SignerRegNumber, session.SessionID),
// дууссаны дараа signer.OrganisationSignature(...) — регистр, нэр, зурсан хүн, цаг, гэрчилгээ.

Файл, нотолгоо: nexus.Files()

Гэрчилгээ, зөвшөөрөл, гэрээний PDF, явцын зураг, албан хариу — модуль дахин харуулах ёстой файлаа workspace.files-д (migration 00148) хадгална:

  • Байгууллагаар тусгаарлагдсан. workspaceID нь context-ийн байгууллага байх ёстой (өөр бол ErrForbidden, байгууллагагүй бол ErrWorkspaceMissing); өөр байгууллагын файл ErrFileNotFound. RLS (FORCE) ч мөн адил.
  • Зөвхөн нэмэгдэнэ. Delete, overwrite байхгүй; өгөгдлийн сан UPDATE, DELETE-ийг trigger-ээр татгалзана. Байгууллага устахад л хамт устана.
  • Хэмжээ: FILES_MAX_MB (анхдагч 25, дээд тал нь 100). Уншихдаа хязгаараас цааш уншихгүй — ErrFileTooLarge (413).
  • Төрөл: байтаас нь таньна (http.DetectContentType): PDF, PNG, JPEG, WebP, энгийн текст. Хэлсэн төрөлтэйгээ таарахгүй эсвэл өөр бол ErrFileType (415). Нэр нь аюулгүй нэр болж цэвэрлэгдэнэ.
  • Шалгах hook: distribution nexus.Provide[nexus.FileScanner](https://github.com/gerege-systems/private-gerege-nexus/blob/main/docs/av) өгвөл файл commit хийгдэхээс өмнө Scan-д орж, татгалзвал юу ч хадгалагдахгүй.
  • Put нь files.put audit мөрийг файлтай нэг transaction-д бичнэ; app_id (аль модуль) нь app gate-ээс, created_by нь session-оос.
  • Байгууллагын хадгалалтын quota-д (storage_mb) тооцогдоно.

Платформын хүсэлтийн биеийн хязгаар 1 MB тул upload route-той модуль nexus.UploadRoutes-ийг хэрэгжүүлнэ: тэр prefix-ийн доорх POST нь файлын хязгаар + 1 MB авна. 40 MB-аас их FILES_MAX_MB нь nginx-ийн client_max_body_size-ийг ч өсгөхийг шаардана.

func (m *Module) UploadPrefix() string { return "/api/v1/property/files" }

func (m *Module) upload(w http.ResponseWriter, r *http.Request) {
    workspaceID, ok := nexus.RequireWorkspace(w, r)
    if !ok {
        return
    }
    file, header, err := r.FormFile("file") // ParseMultipartForm-оор 32 MB хүртэл санах ойд
    if err != nil {
        nexus.Error(w, http.StatusBadRequest, "a file is required in 'file'")
        return
    }
    defer file.Close()
    store, err := nexus.Files()
    if err != nil {
        nexus.Error(w, http.StatusServiceUnavailable, err.Error())
        return
    }
    ref, err := store.Put(r.Context(), workspaceID, header.Filename,
        header.Header.Get("Content-Type"), file)
    switch {
    case errors.Is(err, nexus.ErrFileTooLarge):
        nexus.Error(w, http.StatusRequestEntityTooLarge, err.Error())
    case errors.Is(err, nexus.ErrFileType):
        nexus.Error(w, http.StatusUnsupportedMediaType, err.Error())
    case err != nil:
        nexus.Error(w, http.StatusUnprocessableEntity, err.Error())
    default:
        nexus.JSON(w, http.StatusCreated, ref) // ref.ID-г өөрийн мөрдөө хадгал
    }
}

Буцааж өгөхдөө Open-ийн ref.ContentType-ийг тавьж, X-Content-Type-Options: nosniff болон Content-Disposition: attachment-ийг заавал нэм.

BYTEA бол тааз: файл бүр санах ойд бүтнээрээ уншигдана. Том эзлэхүүнд S3/MinIO хадгалалт яг энэ nexus.FileStore-ийн ард орно — модуль өөрчлөгдөхгүй.

TestTheSDKDoesNotDependOnInternal нь SDK нь internal/-ээс хамаарахгүйг, TestTheExportedAPIIsTheOneOnRecord нь экспортын гадаргуу санамсаргүй өөрчлөгдөхгүйг шалгана. Хоёулаа нэг зорилготой: өөр репод бичигдсэн модуль маргааш ч компиляцлагдах ёстой.

Каталог

Апп нь catalog/apps.json-оор ирнэ. 2026-08-25-наас 09-21 хүртэл энэ репод тэр файл [] байв — платформ нэг ч бизнес аппгүйгээр ачаална, экосистемийн стратегийн шалгуур яг тэр. 2026-09-21-нээс 10-25 хүртэл ганц бичлэгтэй байв (API гарц, internal/apps/apigateway); тэр хасагдсанаас хойш дахин []. Цөмд багтсан модулийн migration-ыг cmd/migrate платформын migration-ы дараа эзэмшигч role-оор хэрэгжүүлнэ (apps.Migrations); distribution өөрийн модулийг pkg/migrate-ийн Options.Modules-оор өөрийн cmd/migrate-д өгнө.

Каталогийн бичлэг нь платформын хувилбарыг нэрлэдэг ("platform": ">=1.1.0") ба суулгац өөрийн хувилбарыг config.PlatformVersion-оос хэлнэ. Тэр утга нь release build дээр ldflags-аар тавигддаг.

Хувилбарын амлалт

pkg/nexus нь нэг major хувилбарын дотор эвдэрдэггүй.

Build шалгаж чадахгүй амлалт бол хэн нэгэн яарсан үдээс хойш хүртэл сахидаг амлалт. Энэ нь хэн нэгэн эвдэхээр шийдсэнээс болж эвдрэхгүй — refactor хийх явцад параметрийн нэр солигдож, эсвэл distribution-ы төрөл хэрэгжүүлдэг interface-д метод нэмэгдэж эвдэрнэ. Хоёулаа энэ репод нэг ч тестийг унагахгүй. Оронд нь distribution бүрийн build хэдэн өдрийн дараа унана.

Тиймээс экспортын гадаргуу нь golden файл:

go test ./pkg/nexus -update    # санаатай API өөрчлөлтийн дараа

Үүнийг өөрчлөх нь зөвшөөрөгдсөн, ихэвчлэн зөв ч байдаг — энэ бол хөлдөөлт биш. Гэхдээ санамсаргүй болох боломжгүй, мөн review дэх diff нь экосистемийн гэрээ юу авсан, юу алдсаныг яг хэлнэ.

Эвдэх өөрчлөлт гэж юу вэ: экспортолсон interface-д метод нэмэх нь эвдэх өөрчлөлт. Тэр interface-ийг өөр репод хэн нэгэн хэрэгжүүлсэн байдаг.

v2-т орох гэж тэмдэглэгдсэн зүйлс кодод Deprecated: гэж бичигдэнэ. Жишээ нь эрхийн дагаврын дүрэм (.read бүгдэд, .manage менежерүүдэд): нэрлэх конвенц нь гэрээ байх ёсгүй — «үйлчилгээнд өргөдөл гаргах»-ыг илэрхийлж чадахгүй, шалгагдаж чадахгүй, түүнийг сонсоогүй модуль болгоны хувьд хэн юу хийж болохыг чимээгүй шийддэг.