Relay is a portfolio demonstration, but it is built the way a real product should be. This document records the decisions rather than claiming a security posture the project does not have.
Relay is a static, client-only application with no backend, no accounts and no real personal data. There is no server to compromise, no database to exfiltrate and no credential to steal. Most of the security work therefore went into keeping it that way.
| Data | Where it lives | Notes |
|---|---|---|
| Fictional demo dataset | Generated in the browser at runtime from src/data/seed.ts |
Invented companies, people and figures |
| Demo state (analyses, drafts, tasks, activity) | localStorage, key relay-demo-state |
The user's own browser only; never transmitted |
| Anything else | — | There is nothing else |
No analytics, telemetry, tracking pixels or third-party scripts are loaded. The font is bundled with the application rather than fetched from a font CDN, so the deployed page makes no third-party network requests at all.
- There are no API keys, tokens or connection strings in this repository, and none are required to run it.
.env,.env.localand.env.*.localare gitignored;.env.exampledocuments the shape of the optional settings and contains no real values.- The only environment variables the app reads are
VITE_-prefixed and deliberately non-sensitive: which AI provider to use, a localhost Ollama URL, and a localhost n8n webhook URL. Anything prefixedVITE_is compiled into the client bundle and is therefore public by definition — that is why no secret is ever read this way. - Local-machine configuration belongs in
.env.development.local, not.env.local, so a production build can never inherit a URL that points at someone's laptop.
There is deliberately none. The entry screen is a demo gate, not authentication: it frames the demo and makes the fictional nature of the data unmissable before anyone sees a dashboard. Because Relay stores no personal data and exposes no privileged operation, adding a login would create the appearance of protection without protecting anything.
If this became a real product, the boundary is already in the right place: every
state change goes through the store, and every workflow through
AutomationService, so authorisation would be enforced at those two seams plus a
real backend.
- All user input (inbox search, the analyst question box, draft editing) is
rendered as text through React, never through
dangerouslySetInnerHTML. There is noinnerHTMLanywhere in the codebase. - The analyst does not evaluate or execute the question; it matches it against a fixed set of known topics and refuses anything else rather than guessing.
- Model output from Ollama is parsed as JSON and type-checked field by field
before use (
extractJson, then explicittypeofguards). A malformed or hostile response falls back to the deterministic result rather than being trusted. - Responses from n8n must contain an
effectsarray or the run is rejected and the local engine takes over.
The deployed demo makes no outbound requests. The two optional integrations both target the user's own machine:
| Integration | Endpoint | When |
|---|---|---|
| Ollama | http://localhost:11434 |
Only when local AI is enabled |
| n8n | http://localhost:5678/... |
Only when VITE_AUTOMATION_ADAPTER=n8n |
Both use AbortSignal.timeout() so a hung service cannot hang the UI, and both
fail closed to the deterministic path.
- No autonomous outbound action. The AI can recommend, classify and draft.
It cannot send, escalate or close anything.
approveDraftis only ever called from a click, and the audit trail records who approved what and whether the draft was edited first. - Confidence is not dressed up as certainty. The score shown is a rule separation score and is labelled as illustrative, uncalibrated and not a probability, next to every place it appears.
- The analyst refuses to answer what the data cannot support, returning an explicit "no supporting data" state rather than a plausible fabrication.
- Numbers are never model-generated, and model claims are validated, not trusted. Even with a local model enabled, priority, deal value, extracted fields and every metric are computed by the deterministic layer, and the briefing and analyst are excluded from the model path entirely. Where the model does contribute wording, its output is checked before it is shown: a proposed next action is rejected unless it restates the response window the priority sets, and a commercial draft is rejected if it drops the calculated price band. Both rejections fall back to the deterministic output, and the first is recorded in the reasoning trail.
- Every AI action is auditable. The activity log records the actor (AI, automation, person or system), the decision and the reasoning behind it.
Runtime dependencies are deliberately few: React, React Router, Zustand,
Recharts, Lucide icons, clsx and a bundled font. npm audit reported no
vulnerabilities at the time of writing. Anyone forking this should run
npm audit again — the number that matters is today's, not the one in a README.
- A React error boundary wraps the application and each page, so one failing panel cannot take down a demo.
- Users never see a stack trace. Failures show a plain message and a retry; details go to the console only.
- Every asynchronous workflow has an explicit loading, success and failure state, and a failed AI call leaves the underlying data untouched.
The project intentionally does not use one — a static demo has no need for a database, and adding one would introduce a service to secure and pay for. If it were added, the requirements would be: row-level security on every table, no service-role key in the client, and the anon key scoped so that reads and writes are impossible without a policy that names them.
This is a demonstration project with no production users. If you find something worth fixing, please open an issue on the repository.