Appearance
locus-web (bank)
Next.js 15 App Router · React 19 · TypeScript · Bun tests · port 3000.
Responsibilities
- Institutional UI (
/workspace/*) — shell, sections, copilot sheet. - BFF
/api/locus/*→ SoR withAuthorization: Bearer <sessionId>+ Zod validation + RBAC guard. - Auth cookie mint (
/api/auth), document upload to R2, OCR orchestration, copilot, exports proxy. - Next.js never spawns OS subprocesses (
exec()); heavy work goes through SoR jobs → workers.
text
Browser ──cookie locus_session──► Edge middleware (ticket HMAC + expiry)
│
▼
App Router / BFF route handlers
requireBffSession ─► enforceRbacGuard ─► Zod
│ Bearer <sessionId> · X-Organization-Id
▼
locus-backend :5000Stack
| Concern | Choice |
|---|---|
| Framework | Next.js 15 App Router, React 19, TypeScript |
| Styling | Tailwind + institutional design system (globals.css, ui/ components) |
| Client data | TanStack Query for deal refresh |
| Tests | Bun (locus-web/tests/) |
| Object storage | @aws-sdk/client-s3 + presigner against Cloudflare R2 |
| Validation | Zod (src/lib/api/validation.ts, schemas.ts, sor-write-schemas.ts) |
| Queue | BullMQ when REDIS_URL is set; in-memory Map otherwise (local/tests) |
Code layout
| Zone | Path | Role |
|---|---|---|
| App routes | src/app/workspace/*, src/app/api/* | Pages + BFF |
| Workbench | src/components/workbench/ | WorkbenchView, workspace-routes.ts, WorkspaceSection.tsx |
| Domains | src/domains/{portfolio,market,fixed_income,regulatory,auth,editions,dataroom,queue,jobs,…} | TS business logic |
| Regulatory twin | src/regulatory_engine/ (+ domains/regulatory/) | analyzeDeal, analyzeDealWithVault, catalog, versioning, applicability |
| Copilot | src/copilot/ | engine, agent, async, retrieval, llm, financials, tools/* |
| Storage | src/lib/storage/{r2,document-upload,ocr-ingest,ocr-worker-client,vault-crypto}.ts | Vault + ingest |
| SoR client | src/lib/api/locus-server.ts, locus-bff.ts, locus-browser.ts, legal-vault.ts | Session → guard → Bearer SoR |
| Auth | src/middleware.ts, src/lib/auth/{session-ticket,session-cookie}.ts, src/domains/auth/ | Edge gate, RBAC matrix, enforceRbacGuard |
| Local engines | engine/ratio_calculator.ts, credit_lab/, risk_engine/, dataroom_completeness/ | Client-side deterministic math |
Workspace routes
Declared in workspace-routes.ts (WORKSPACE_SECTIONS, 28 sections) and rendered by WorkspaceSection.tsx. URL shape: /workspace/{section}?project={dealId}.
| Zone | Sections |
|---|---|
| Work | overview, deals, new-dossier, deal-room, dataroom-completeness, deal-qa, deal-graph, diligence-runner, issuers, instruments, portfolio |
| Analysis | financial-tables, ratio-cockpit, credit-lab, review-grids, contradiction-radar, risk-register, regulatory, cosumaf-auditor, sovereign-curves, fixed-income-terminal |
| Monitoring | market-radar |
| Knowledge | research, institutional-memory, memo-drafter |
| Admin | profile, org-members, org-settings |
sovereign-curvesandfixed-income-terminalboth renderFixedIncomeTerminalView;market-radarandresearchboth renderMarketIntelligenceView(see Workspace route map).platformOpsRedirectPath()maps legacyaccess-requests→/access-requestsandregulatory-admin→/regulatoryonlocus-ops(absolute URL viaNEXT_PUBLIC_OPS_APP_URL).
Institutional identity (UI)
| Element | Source |
|---|---|
| Sidebar | Logo + legal name of the organization (orgProfile.logoDataUrl / companyName) — not the user name |
| Navbar | Profile avatar (avatarDataUrl) through UserAccountMenu |
| Admin | /workspace/org-settings (logo, name, jurisdiction), /workspace/org-members (SMTP invites, roles, module overrides) |
| COSUMAF attestation | Locus product logo (/logo.svg) + arranger = authenticated organization |
| Fresh tenant | Workspace is empty until a dossier is created; SoR unreachable → empty institution shell, never a branded demo firm |
Auth gate (middleware)
src/middleware.ts (matcher: everything except _next/static, _next/image, favicon.ico):
| Path class | Behaviour |
|---|---|
Static assets (/_next, .png, .svg, .css, …) | pass |
| Public pages | /, /landing, /login, /register, /auth/login, /auth/forgot-password, /auth/otp, /auth/reset-password, /pricing, /request-access, /request-access/verify, /onboarding, /auth/accept-invite, /legal + /legal/{mentions-legales,confidentialite,conditions,securite} |
| Public API prefixes | /api/onboarding, /api/regulatory/search, /api/auth, /api/locus/members/accept-invite, /api/locus/access-requests |
Other /api/** without valid ticket | 401 { error: "UNAUTHENTICATED" } |
/workspace/** without valid ticket | redirect /login?redirect=… |
The ticket is read from cookie locus_session (or __Secure-locus-session) or Authorization: Bearer; only signed v1. tickets are accepted — legacy unsigned sess_* cookies are always rejected. There is no demo bypass. Workspace layout.tsx additionally resolves the session through the SoR (fetchSorSession) and redirects if it is gone. Ticket format and lifecycle: Auth & sessions.
BFF → SoR proxy (/api/locus/*)
Every handler runs requireBffSession(req, '<permission>') → enforceRbacGuard, then forwards Authorization: Bearer <sessionId> + X-Organization-Id derived from the session (admin headers are never trusted in production).
| BFF route | Upstream SoR |
|---|---|
/api/locus/deals | GET/POST /api/deals |
/api/locus/deals/[id] | GET /api/deals/{id} |
/api/locus/deals/[id]/status | PUT /api/deals/{id}/status |
/api/locus/deals/[id]/archive · unarchive | POST /api/deals/{id}/archive · unarchive |
/api/locus/deals/[id]/reconciliation | GET /api/deals/{id}/reconciliation (fallback reconciliationService.evaluateReconciliation) |
/api/locus/deals/[id]/cells/[cellId]/override | POST /api/deals/{id}/cells/{cellId}/override (Zod CellOverrideSchema) |
/api/locus/deals/[id]/analysis · jobs | POST /api/deals/{id}/analysis · GET /api/deals/{id}/jobs |
/api/locus/analysisjobs/[jobId] | GET /api/analysisjobs/{jobId} |
/api/locus/deals/[id]/qa · qa/[threadId] | api/deals/{dealId}/qa |
/api/locus/deals/[id]/regulatory-findings | GET/PUT /api/deals/{dealId}/regulatory-findings |
/api/locus/documents/upload | R2 put + POST /api/documents + OCR persist |
/api/locus/documents/[id]/content | Authenticated decrypt proxy (AES-256-GCM objects) |
/api/locus/documents/[id]/url | Presign R2 GET (legacy plaintext objects) |
/api/locus/documents/[id]/verify-citation | POST /api/documents/{id}/verify-citation (±5 px) |
/api/locus/legal-vault/resolve · artifacts/[id]/content | GET /api/legal-vault/resolve · artifacts/{id}/content |
/api/locus/members* | invites, roles, audit, resend, accept-invite |
/api/locus/organization | Profile (logo / avatar / name) |
/api/locus/exports/[...type] | POST /api/exports/* (PDF / Excel) |
/api/locus/search · blockers · portfolio · market · precedents · alerts | api/search, api/blockers, api/portfolio/book, api/market/overview, api/precedents, api/alerts |
/api/locus/market/ingest | POST /api/market/ingest |
/api/locus/access-requests · verify · [id]/review · [id]/provision | submit (public) / list (system:manage) / verify (public) / review / provision |
/api/locus/storage/health | R2 ping |
Other non-proxy routes: /api/auth (login / OTP / logout / cookie), /api/regulatory/* (audit, ingest, search), /api/jobs/*, /api/pipeline, /api/copilot/polish-stream.
Zod validation
Every /api/locus/* handler imports validation.ts; mutating routes use parseJsonBody / validateRequestBody against schemas.ts (CreateDealSchema, ArchiveDealSchema, MemberInviteSchema, AccessRequestReviewSchema, DocumentUploadFormSchema, VerifyCitationSchema, CellOverrideSchema, search/blockers query schemas, …). Invalid input returns a structured 400 VALIDATION_ERROR with field paths — never stack traces. Market ingest uses MarketIngestSchema (sor-write-schemas.ts, max 1000 rows per collection).
Other BFF guards: sliding-window rate limiter (60 req/min → 429 + Retry-After), 401 UNAUTHENTICATED / 401 SESSION_EXPIRED, 403 TENANT_FORBIDDEN.
Security headers & secrets
2-year preloaded HSTS (max-age=63072000; includeSubDomains; preload), X-Frame-Options: DENY, strict CSP. SecretsManager (src/lib/security/secrets.ts) reads process environment only and fails closed in production on forbidden placeholders. Cookie options: httpOnly, secure in production, sameSite=lax, path=/.
Environment (locus-web/.env.example)
| Variable | Role |
|---|---|
LOCUS_API_BASE_URL | C# SoR (default http://localhost:5000) |
NEXT_PUBLIC_APP_URL | Public URL of the bank app (build-time) |
SESSION_SIGNING_KEY | HMAC key for locus_session tickets — required in production (openssl rand -hex 32) |
DOCUMENT_ENCRYPTION_KEY_HEX | AES-256-GCM at rest for R2 objects — required when NODE_ENV=production |
LOCUS_ALLOW_PLAINTEXT_DOCUMENTS | Emergency only; never in prod |
R2_SIGNING_KEY, REGULATORY_ATTESTATION_HMAC_SECRET | Presign / attestation signing |
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_ENDPOINT | Cloudflare R2 (default endpoint https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com) |
REDIS_URL | BullMQ; required in production |
LOCUS_ALLOW_INMEMORY_QUEUE | Emergency only |
LOCUS_WORKER_URL (alias PYTHON_WORKER_URL) | Python worker for Tesseract OCR (default http://127.0.0.1:8000) |
COPILOT_LLM_PROVIDER | off | openai | anthropic |
OPENAI_API_KEY / ANTHROPIC_API_KEY | Prose polish only |
COPILOT_OPENAI_MODEL / COPILOT_ANTHROPIC_MODEL | Optional model overrides |
LOCUS_ORG_ID, LOCUS_DEV_USER_ID, LOCUS_DEV_USER_ROLE | Dev-only header mode — never production |
NEXT_PUBLIC_OPS_APP_URL | Redirect target for legacy ops routes |
Coolify: set runtime secrets as available in container, not during build; NEXT_PUBLIC_* need a redeploy. Never set LOCUS_ALLOW_* in production. See Deploy & env.
Tests
bun test in locus-web/tests/ (e.g. auth_gate_and_api_rbac, session_ticket_middleware, auth_otp_and_register, bff_locus_validation, bff_analysis_jobs_contract, dossier_persistence_rbac, market_portfolio_search_rbac, document_encryption_at_rest, fail_closed_integration). Web Python tests live alongside locus-workers/tests/.