The component specification
Table of Contents
This document specifies what ores.web contains today. Read
component_overview.org for the component boundary, its inputs and its outputs.
The interface is not built yet. The component is a shell and a transport. It holds a session and it reaches the server, and no screen is wired to it. This document specifies those parts and nothing else.
1. The five packages
The component is one npm workspace with five packages. Each package has one job.
| Package | Path | Job |
|---|---|---|
@ores/web |
packages/web |
The browser application: the shell, the session, the i18n catalogue, and the styling. |
@ores/bff |
packages/bff |
The Fastify server. It owns the NATS connection and the session token. |
@ores/contracts |
packages/contracts |
The HTTP shapes shared between the server and the interface. It has no Node dependency, so the browser can parse them. Only the BFF imports it today, and the browser parses with @ores/wire-protocol/browser instead; the intent is that both sides parse one schema and the split is not yet finished. It depends on @ores/wire-protocol through that package's time entry point, which imports nothing but Zod: an instant is spelled the same way on the bus and over HTTP, so both packages read the one owner of that spelling rather than keeping a tolerant copy each. |
@ores/wire-protocol |
packages/wire-protocol |
The ORE NATS protocol: the msgpack codec, the subjects, the schemas, the session lifecycle, and the mTLS transport. |
@ores/org |
packages/org |
Parses org text into a line-indexed outline and renders it as safe HTML. The outline entry has no dependency and serves the BFF, which reads and writes scenario docs. The render entry serves the browser. |
Only @ores/wire-protocol carries generated code. See §6.
2. The browser shell
packages/web/src/main.tsx is the entry point. It composes four providers and
renders one placeholder.
main.tsx
AppProviders one TanStack Query client
TranslationProvider the i18n catalogue and the chosen language
SessionProvider the session state for the whole application
BrowserRouter the router, with no routes registered yet
App the placeholder
The provider order is deliberate. The session query needs the query client, and the placeholder needs the session.
The placeholder states that the interface is not built, and it prints one line for the session. The line says "Signed in as <username>.", "Checking the session…", or "No session.". A shell that cannot tell those three states apart is not a shell anybody can debug.
BrowserRouter is mounted with no routes. The shell is ready for routes, and it
has none.
3. The session
3.1. Where the session lives
The browser holds an opaque identifier in a cookie. It does not hold the bearer token, and it cannot decide locally whether it is signed in.
| Property | Value |
|---|---|
| Cookie name | ores_web_session |
| Flags | httpOnly, sameSite: 'lax', secure when the deployment sets it |
| Lifetime | The configured session TTL |
The BFF holds the mapping from that identifier to the session record. The record carries the bearer token and the party the session is scoped to. The token never enters the browser.
3.2. How the browser reads the session
The browser asks the server. It does not infer the answer.
SessionProvider runs one query on load and publishes the result to the whole
application. The state is loading, authenticated, or anonymous. A request
that fails with status 401 means no session, and that is not an error.
3.3. The session calls
| Call | Purpose |
|---|---|
login(username, password) |
Opens a session. |
session() |
Reads the open session, or returns null. |
selectParty(partyId) |
Scopes the session to a party. |
logout() |
Closes the session. |
4. The transport to the BFF
4.1. The boundary
The browser talks to the BFF over HTTP only. It never reaches NATS. The broker requires mutual TLS, and no browser can present a client certificate on a WebSocket. The BFF is therefore mandatory, and it is also the stronger boundary: the browser is not told the host, the port, the namespace, or the certificates.
4.2. The client
packages/web/src/api/ holds the browser side of the boundary.
| Module | Job |
|---|---|
transport.ts |
Sends a request, parses the JSON, and raises ApiFailure on a non-success status. |
client.ts |
The typed calls: the session, the starting points, the provision request and the run it starts, the password policy, and the signed-in account's own password. |
journeys/ |
The journey runtime, the first run journey, and the step library the tenant journeys place on their rails. |
Every response is parsed against a Zod schema from
@ores/wire-protocol/browser. A response that does not match its schema is a
failure, not a surprise later.
The shell reads the session; the first run journey reads and writes everything else. A route below that no module calls yet is served by the BFF and reachable, and the journey that needs it adds the call.
The rule a journey follows is Journey runtime: one hand-written route per step, each parsing a generated schema, and no generic route that takes a subject from the browser. This table is therefore the browser's whole authority, and a step that needs an operation no route exposes is a missing route rather than a reason to widen one.
The BFF is the browser's only HTTP surface. ores.http is the platform's REST
gateway for callers outside the browser, and no route here forwards to it.
4.3. The HTTP surface
The BFF serves these routes on ORES_WEB_PORT.
| Route | Purpose |
|---|---|
POST /api/session |
Sign in. |
GET /api/session |
Read the session. |
DELETE /api/session |
Sign out. |
POST /api/session/party |
Select the party for the session. |
POST /api/session/switch-party |
Re-scope an open session to another of the account's parties. The party may have been added since the login, so the server reads the tenant's parties rather than taking the caller's word for the chosen one. |
GET /api/bootstrap |
Whether the deployment still needs its first administrator. Served with no session, because a deployment in bootstrap mode has nobody to sign in as. |
POST /api/bootstrap/administrator |
Create the first administrator, which closes bootstrap mode. Refused unless the deployment says it is still in bootstrap mode. |
GET /api/password-policy |
The rules a password must satisfy, read from the server's validator. Served with no session, because the screen that shows the rules is the one a person signs in on. |
POST /api/account/password |
Change the signed-in account's own password. The account is the session's own, so the route takes no account id. |
GET /api/accounts |
The accounts the caller may see, one page at a time. The administrator's screen opens on this list and finds the colleague the support call is about. |
GET /api/accounts/:username |
One account by username. A username that is not there answers with nothing, because the caller names a row it has just read: the row having gone since is an expected state and not a failed call. |
PUT /api/me/profile |
Write the signed-in person's own full name, job title and picture. The session names the account, and the three fields a member does not own are sent empty. |
PUT /api/me/contact-information, GET /api/me/contact-information |
Read and write the signed-in person's own contact record. The write names no record: an account without one gets it on the first write. |
PUT /api/accounts/:username/profile |
Write one account's profile fields, as a tenant administrator. The route reads the account and sends the record whole, so the fields this screen does not set are kept. |
GET /api/accounts/:accountId/contact-information, PUT /api/accounts/:accountId/contact-information |
Read and write one account's contact record, as a tenant administrator. The write carries the version the panel read as its claim, and the store refuses a record that moved. |
GET /api/login-info |
The tenant's login records, one page at a time. The audit screen reads the failed attempts and the lock state from here. |
GET /api/login-info/:accountId |
One account's login record. An account that has never signed in has none, which is an answer rather than an error. The record carries no credential column. |
GET /api/seed-profiles |
The starting points a new tenant may be provisioned from, each with the step kinds it orders and the parameters its form declares. |
POST /api/provision-tenant |
Provision a tenant from one starting point. The tenant and its administrator exist when it answers, and the answer carries the id of the run that provisions the rest. |
POST /api/provision-party |
Add one party to the tenant the caller works in, and start the run that brings it to life. The party row is written under the tenant's own party, and the run publishes the party's data, activates it, records the legal entity it was built from and joins the caller to it. |
GET /api/workflow/:instanceId |
A run's progress: its state, the step it is executing, and one summary per step. This read is the progress contract every journey's rail renders, and it names the run rather than the journey that started it. |
POST /api/workflow/:instanceId/retry |
Resume a stopped run from the step that failed. A refusal is an answer, not a failed call. |
GET /api/site |
The site description. |
GET /api/site/deployment |
The deployment description. Served only when the developer surface is on. |
GET /api/health |
Liveness. |
GET /api/change-reasons |
The audit reason list. |
GET /api/images/:id |
One image of the session's own tenant, such as a flag or a picture, by its identifier. |
POST /api/images |
Upload one image and answer its identifier. The upload sets no photo: the identifier rides into the write that references it. A refused image is an answer with the server's code, not a failed call. |
GET /api/image-upload-policy |
The rule an uploaded image must satisfy, read from the validator that enforces it. |
GET /api/tenants/:code/images/:id |
One image of a tenant read from system administration. The tenant's parties and people pages read the images they name in the same visit, so this route answers from what they kept. |
DELETE /api/tenants/:code |
Remove a tenant. The body carries the tenant's code typed again, and the system tenant is refused. |
GET /api/me/access |
The roles the signed-in person holds, with what each grants and who gave it, when and why. |
GET /api/accounts/:accountId/access |
The roles one account holds. The server allows it to a holder of iam::roles:read. |
POST /api/accounts/:accountId/roles, DELETE /api/accounts/:accountId/roles/:roleId |
Give a role for a reason, and take one away. |
GET /api/accounts/:username/picture |
The picture of an account of the session's own tenant, for any screen that names an account. |
GET /api/roles, POST /api/roles, PUT /api/roles/:roleId, DELETE /api/roles/:name |
The role catalogue, each role with what it grants; create, rename and delete. |
PUT /api/roles/:roleId/permissions |
Replace what a role grants with the whole set sent. |
GET /api/permissions |
Every permission the platform defines. |
GET /api/accounts/:username/sign-ins |
One account of the session's own tenant: its sign-in state and a page of its sessions, newest first. |
GET /api/me/requests, POST /api/me/requests |
The approval requests the signed-in person raised, newest first, each with the roles it asks for and the latest thing said about it; and asking for roles, which answers the request raised. The @kind@ and @state@ codes are the server's own, and the screen translates them. A caller who may read neither the request roles nor the account list gets their own requests with no role names and the account id where a username would be, because the join is refused rather than the page. |
DELETE /api/me/requests/:id |
Take back a request the person raised, against the version they read. |
GET /api/requests, POST /api/requests/:id/decision |
The open requests the signed-in person may decide, oldest first, and approving, refusing, holding or resuming one against the version read. The server picks the queue from the permissions each kind names, so the BFF gates these on a session alone and never on a mode. |
GET /api/me/notifications, GET /api/me/notifications/unread-count |
The signed-in person's uncleared notifications, newest first, with the message key and the values its wording names; and how many are unread, for the bell on every screen. |
POST /api/me/notifications/read, POST /api/me/notifications/clear |
Mark notifications read, and remove them from the list. Each names the notifications to act on in a required @ids@: an empty list means every unread one, or every read one for a clear, so a body that forgot the field is refused rather than read as empty. |
GET /api/tenants/:code/accounts/:username/sign-ins |
The same for an account of a tenant, read from system administration inside the tenant, or as the session for the system tenant. |
GET /api/me/sessions |
The signed-in person's own open sessions. |
GET /api/classifications |
The classification lists the screen maintains, by topic, each with its columns, whether it may be changed, the permissions the server checks, and how many rows it holds. The list keys are the BFF's authority: a key it does not name reaches nothing. |
GET /api/classifications/:list |
Every row of one classification list, each with the badge code of its label in the shared label catalogue. |
PUT /api/classifications/:list/rows/:code/label |
Give a row a label from the shared catalogue, or take it away with a null badge. A label is how a code is drawn, not how ORE spells it, so a read-only list may be labelled. |
GET /api/labels |
The shared label catalogue: every badge, and the badges each code domain uses. |
GET /api/refdata |
The reference data resources the screens read and write, each with its key fields and the permissions the server checks. The resource keys are the BFF's authority: a key it does not name reaches nothing. |
GET /api/refdata/:resource, GET /api/refdata/:resource/by/:parent |
Every row of a resource, or the rows of one parent, such as a currency's countries or a calendar's rules, in the server's own field names. |
PUT /api/refdata/:resource, DELETE /api/refdata/:resource |
Write one row, new or against the version read, and remove one named by exactly its key fields, each with a change reason. Each write is checked against the resource's own schema. |
GET /api/refdata/calendars/:code/days |
The materialised days of one calendar in one year. The dates filter takes no range, so the BFF pages through the calendar's days until it passes the year. |
POST /api/refdata/calendars/:code/rebuild |
Build one calendar's business days up to the end of a year. The server adds only the days it has not built. |
GET /api/synthetic |
The synthetic resources the screens read and write, each with whether this session may write it and why not, what it searches and sorts, and the permissions the server checks. The resource keys are the BFF's authority: a key it does not name reaches nothing. |
GET /api/synthetic/:resource, GET /api/synthetic/:resource/key/:key, GET /api/synthetic/:resource/by/:parent |
Every row of a resource, or one page of it with the total, searched and ordered on the server when the request names a limit; one row by its key; and the rows of one parent, such as one feed's mixture. |
PUT /api/synthetic/:resource, DELETE /api/synthetic/:resource |
Write one or many rows in one call, each new or against the version read, and remove one row against its version or many unconditionally, each with a change reason. A write that states its party is refused: the party is the session's. |
POST /api/synthetic/simulate/fx, POST /api/synthetic/simulate/ir, POST /api/synthetic/preview/ir-shape |
Sample paths and a curve shape drawn from the values on a screen; nothing is saved. |
POST /api/synthetic/convert/fx |
The mixture an FX engine change leaves, converted by the synthetic service (synthetic.v1.ops.convert_fx_mixture): μ and σ scaled by the initial price between log-returns and price units, one component with κ from a 15 minute half-life into Ornstein-Uhlenbeck, and one with the whole weight out of it. Nothing is stored. |
POST /api/synthetic/convert/ir |
An IR engine's parameters read as the simple view (the start rate, the long-run rate, the half-life in years and the yearly move in basis points), or the simple view written as the engine's parameters, converted by the synthetic service (synthetic.v1.ops.convert_ir_parameters). Each engine reads σ in its own unit, and Black-Karasinski reads θ as a logarithm. The reply says when the parameters have no simple form, and warns when a Cox-Ingersoll-Ross rate can touch zero. Nothing is stored. |
POST /api/synthetic/convert/fx-simple |
An FX mixture read as the simple view (the drift, the volatility and the jump share per hour, or the half-life in minutes for the mean-reverting engine), or the simple view written as a mixture, converted by the synthetic service (synthetic.v1.ops.convert_fx_simple). The reply says when the simple view only approximates the mixture. Nothing is stored. |
POST /api/synthetic/feeds/:id/start, POST /api/synthetic/feeds/:id/stop, POST /api/synthetic/folders/:id/start, POST /api/synthetic/folders/:id/stop, GET /api/synthetic/running |
Start and stop one feed or a folder's feeds, and the feeds running now. |
GET /api/synthetic/feeds/:id/ticks |
The newest ticks of one FX feed's current run, oldest first, read after the feed is read as the session. The BFF listens on the tick subject of each feed the running read names and keeps the newest 500; a run's ticks go when it stops, because a source name is unique only within a tenant. |
POST /api/classifications/:list/rows, PUT /api/classifications/:list/rows/:code, DELETE /api/classifications/:list/rows/:code |
Add a row, correct one against the version read, and remove one, each with a change reason. A list of ORE spellings is refused. |
PUT /api/classifications/:list/order |
Write the new display order of the rows that moved, in one call. |
GET /api/history |
Every version of one record, newest first, with the field-level changes in each. Served only for the entity types the BFF names: the classification lists, the versioned reference data and synthetic records, and the tenant. |
GET /api/events, POST /api/events/watch |
Change notifications. See change_notification.org. |
The BFF also serves the built browser bundle from packages/web/dist.
5. The i18n catalogue and the styling
5.1. Languages
The interface speaks three languages from the beginning: English, Portuguese, and French. One language hides every translation fault, because a missing key looks like a working screen. Three make those faults visible while the interface is small enough to fix them cheaply.
English is the source language and the fallback.
The browser's own preferences are read first, matched on the primary subtag, so
pt-BR finds pt. The chosen language is stored per browser under
ores.web.language, because the choice belongs to the person reading the screen
and not to the record.
5.2. Keys
Text is reached by key, never by English words in the code. The English words
live in the catalogue under the same keys. The catalogue is in
packages/web/src/i18n/locales/.
5.3. Styling
packages/web/src/styles.css holds the Tailwind entry and the theme tokens.
Components use the tokens rather than raw colours.
6. The wire-protocol package
@ores/wire-protocol is the one package that carries generated code.
| Area | Content |
|---|---|
| Generated | src/generated/{component}/protocol/{entity}_protocol.ts and src/generated/{component}/domain/{entity}.ts, from the org entity models. |
| Hand-written | The codec, the subjects, the schemas, the session lifecycle, the mTLS transport, and the browser entry point. |
ores.codegen writes the generated files from the same org models that drive the
C++ headers, so the two projections cannot drift. The generated tree is
committed and the codegen drift gate covers it. Change the org model, then
regenerate; do not edit a generated file.
The package exposes a browser entry point, @ores/wire-protocol/browser, for the
shapes that carry no Node dependency.
7. Decisions this document makes
| Decision | Why |
|---|---|
| The browser holds an opaque cookie, not a token | The token is the broker credential. Keeping it server-side means a cross-site scripting fault cannot steal it. |
| The browser never reaches NATS | The broker requires mutual TLS, which a browser cannot present. |
| Three languages, not one | A missing key and an over-long layout both look like working screens in a single language. |
| The session is a server question | The browser cannot read an opaque cookie, so it asks rather than guessing. |
| One query client, one session provider | Every request shares one cache and one session state. |
| The shell ships before the screens | The transport and the session are verifiable on their own, and a screen built on an unverified transport hides which of the two is broken. |
8. Open questions
When do the screens arrive? The shell holds no route. The build order is deliberate, but the placeholder is not a destination.
Is the developer surface permanent? /api/site/deployment is served only when
the deployment switches the developer surface on, because it names the host, the
port, and the namespace.
9. See also
- ores.web — the component boundary, its inputs, its outputs, and its entry points.
- Change notification — the channel to the browser and the payload.
- Protocol types are generated from the C++ codegen model — the decision behind the generated wire types.
- Component architecture — the component layout conventions this component follows.