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

Emacs 29.3 (Org mode 9.6.15)