Security architecture
This page is for readers who need to assess toro4n before it is used with sensitive material. It describes how the service at https://toro4n.com handles models and account data: where data is stored and processed, which network requests the app makes, which controls protect the data, how those controls are verified, and which risks remain.
In summary: models saved online are stored by toro4n on Cloudflare within the EU, encrypted in transit and at rest. They can be shared with named people, and the user can allow AI assistants to work with them. Access to them is controlled by the server; they are not end-to-end encrypted. Models that are not saved online are stored only in the browser, and for a user who does not sign in, no model data is ever transmitted. The app loads nothing from a third-party origin and sends requests only to its own origin.
Architecture
Where processing happens. The app is a static bundle (HTML, a main JavaScript module, separate modules for rarely used dialogs, one stylesheet, and self-hosted fonts) served from https://toro4n.com/app/. All editing logic runs in the browser. For a model saved online, the server also checks, orders, stores, and relays each batch of changes, and it applies the changes an AI assistant makes, as described under Accounts and online models and under AI assistants. For a visitor who is not signed in, the app makes two API requests in normal use after load: GET /api/session, which answers whether a session cookie is valid, and, when a model is created, an empty POST /api/model-counts, which adds one to a daily count. If an uncaught error happens in the app, it also sends a short error report to POST /api/errors without cookies or model data, and if the visitor was sent by an AI assistant that asks for access, it fetches the assistant's name (GET /api/oauth/request) and, on Cancel, declines (POST /api/oauth/deny); none of these carry model data. https://toro4n.com/ is a separate static start page that loads no script; the page and everything it loads (stylesheets, fonts, and images) are served from the app's own origin. The page with the report form (/report/) loads a same-origin script that posts the form to the server. The server redirects /app, and any address on / that carries a query, to /app/ with the same query. It also redirects www.toro4n.com, toro4n.app, www.toro4n.app, and plain http to https://toro4n.com.
Network requests. All requests go to the app's own origin. They are: a GET for a bundled sample model, triggered by Open sample, the link in the first-run hint, or the /app/?open= parameter, whose value must be a bare file name matching ^[\w-]+\.json$; GET /api/session at start; when a model is created, an empty POST to /api/model-counts (with credentials omitted and keepalive, and no body); if an uncaught error happens, a POST to /api/errors (with credentials omitted and keepalive, at most five distinct reports per page load) carrying only the error's type identifier and stack frames in scripts on toro4n.com (function name, file path, line, and column), never the error's message or anything from a model; for a visitor sent by an AI assistant, the two requests described above; and, only when the user signs in or works with a model saved online, JSON requests under /api/ and one WebSocket to /api/models/{id}/live. Shortly after the app starts, it also makes same-origin GET requests for the script files of the dialogs kept outside the main script (Help, Export, Import, My models, Account, Share, Version history, Plans, and the AI assistant permission dialog); a dialog opened before then is fetched when it is first opened. If the page is closed with changes to an online model that are not yet confirmed, they are also sent with a keepalive fetch to /api/models/{id}/ops. There is no XMLHttpRequest, sendBeacon, or service worker, and no script, stylesheet, font, or image is loaded from another origin. The daily count of created models is the only usage measurement. Four navigations leave the app's origin: Google sign-in, in a separate window that the server redirects to Google and that Google returns to the server's callback; the answer to an AI assistant's request for access, which takes the page to the address the assistant registered; Stripe's hosted checkout, when a user buys a plan; and Stripe's customer portal, opened from Billing and invoices.
Storage in the browser. Models that are not saved online are stored in IndexedDB (database graphbuilder, object store documents) under the toro4n.com origin, unencrypted, each as JSON text with the same content as a saved file. localStorage holds the light or dark theme, whether Simple mode is on (key graphbuilder:simpleMode; a display setting that is never part of a model), whether the notice shown on small screens has been dismissed (key graphbuilder:smallScreenNoticeDismissed), whether the side panel is collapsed (key graphbuilder:sidePanel), whether the first-run hint has ended, which happens once a model with a relationship is open (key graphbuilder:firstRelationship), the last active layer of each model (keys graphbuilder:activeLayer: followed by the model's id; the value is a layer id; removed when the model is deleted), which folders in My models are collapsed (key graphbuilder.closedFolders; folder ids), and, only when the page is left before the latest change has reached IndexedDB, a copy of the open model (key graphbuilder:unsaved) that is moved to IndexedDB and removed at the next start. Tabs in the same browser notify each other through a BroadcastChannel when a model has been saved or deleted, or when the user has signed in or out; these messages carry only model ids (with a timestamp for saves) or the fact that a sign-in or sign-out happened, never model content or an email address, and they stay inside the browser. The result of a Google sign-in, described under Sign-in with Google, is also posted over BroadcastChannel and can include the email address Google returned. While a sign-in code is awaited, sessionStorage holds the email address and the challenge token for that sign-in (key graphbuilder:signin). While a sample model is open and has not been renamed, sessionStorage holds its current content (key graphbuilder:example) so that a reload of the tab keeps it; the browser discards it when the tab is closed, and it is removed when another model is opened or the sample is renamed and saved to IndexedDB. The content of a model that is open from the server is never written to IndexedDB or localStorage; only its id is kept, in IndexedDB (key meta:lastOnline) so that it can be opened again, and in localStorage with its last active layer. Undo history exists in memory only. Save and export create local downloads from Blob URLs; Copy writes to the system clipboard.
URLs, titles, and referrers. Model content is never placed in the URL, the document title, or a referrer. A link to a shared model (/app/?model=…) carries only the model's random identifier, which gives no access by itself, and the parameter is removed from the address as soon as the page has read it.
Accounts and online models
Saving a model online. A model is sent to the server only when a signed-in user chooses Save online for it or drags it onto Online in My models; sharing requires that step first. The model is then moved: it is stored on the server and removed from the browser's storage. Every other model stays in the browser as described above. While a model saved online is open, the top bar shows its online status; a model stored in the browser shows no status there.
Protection of models online. A model saved online is sent over TLS and stored as JSON in the model's own Durable Object; the list of models and the accounts are in a D1 database. Cloudflare encrypts both at rest. The application does not add encryption with keys held only by the users, so the operator (Lantz Hedström Consulting AB) and the hosting provider can technically access stored models. The application has no function for reading other people's models: every request for a model is checked against its owner, the people it is shared with, and, for a team model, the team's manager, as described below.
Sign-in. Authentication is by a six-digit code sent to an email address, or through Google. Apart from the review account described next, accounts have no passwords. A code is valid for ten minutes, for five attempts, and for one use, and only together with a 256-bit challenge token held by the browser that asked for it. Requests for codes are rate-limited per email address and per client address, and attempts to use a code are rate-limited per client address. The server stores an HMAC-SHA-256 of the code and a SHA-256 of the challenge, never the values themselves, and answers identically whether or not the address has an account.
Review account. One account, review@toro4n.com, signs in with a password instead, because app directories require a demo account that their reviewers can sign in to without codes or two-step verification. It holds only sample models. The password is held as a Worker secret, never stored in the database or the code, and is compared as an HMAC-SHA-256. Attempts are rate-limited per client address and in total, and a wrong address and a wrong password get the same answer. No other address can sign in with a password.
Sign-in with Google. Google sign-in uses OpenID Connect's authorization code flow with PKCE (S256) and the scopes openid and email, in a separate window opened from the app; the app's own page loads nothing from Google. Before redirecting to Google, the server sets the cookie __Host-toro4n_google (Secure, HttpOnly, SameSite=Lax, Max-Age ten minutes) holding a 256-bit random key; the state, nonce, and PKCE verifier are HMAC-SHA-256 values derived from that key with the server's secret, so nothing is stored for a pending sign-in. On return, the server compares state with the cookie and clears it, exchanges the code with Google's token endpoint using the client secret and the verifier, and checks the ID token's issuer, audience, expiry, and nonce. The token is received directly from Google over TLS, so its signature is not verified separately (OpenID Connect Core 3.1.3.7). The account is the email address, as with a code, and is accepted only when email_verified is true and Google is authoritative for it: a gmail.com or googlemail.com address, or a Google Workspace domain (the hd claim). Any other address must use a code. Nothing else from the Google account is stored. Callbacks are rate-limited per client address. The window ends on a page from the server whose script posts the result to the app's tabs over BroadcastChannel and closes the window.
Session. A successful sign-in sets one cookie, __Host-toro4n_session, with the attributes Secure, HttpOnly, SameSite=Strict, Path=/, and Max-Age of 30 days. It holds a 256-bit random token, of which the server stores only the SHA-256. Page script cannot read it. A session ends when the user signs out, or 30 days after it was last extended, which happens at most once a day while it is in use. Requests that change anything, and WebSocket handshakes, are accepted only with an Origin header equal to the app's own origin, and a request that the browser marks as coming from another site (Sec-Fetch-Site) is refused.
Authorization. Every request for a model is checked against the model's owner, the email addresses it is shared with, and, for a team model, the team's manager, as described under Paid plans. A WebSocket connection is checked when it is opened, immediately when the model's sharing changes, before a change is accepted if more than 60 seconds have passed since the last check, and at least every five minutes while it is open; it is closed immediately when the session it was opened with is signed out. A request for a model that the requester may not access is answered exactly like a request for a model that does not exist. There are three roles: owner, who can also share, delete, and hand the model to someone it is shared with who has signed in (the previous owner becomes an editor); can edit; and can view, which the server enforces by refusing every change from that role. There is no public link: a model is shared with named email addresses, and a person must sign in with that address to read it.
Ordering and validation. The server checks the shape of every batch of changes before it is stored and numbered, and reads every full copy of a model through the schema used for imported files. Each batch is relayed to everyone, its sender included. Every browser applies the batches in that order with the same rules, validating each operation and every node, relationship, note, and layer that results from it against the same schema; a batch that does not validate is skipped by every browser alike, so all browsers arrive at the same model. When two people change the same field, the change that reached the server last applies.
Earlier versions. An earlier version of an online model is a full copy of the model at a position in the order of changes. A browser that may edit the model uploads one after five minutes without changes, or after at most 30 minutes of continuous changes; the server keeps the first one it receives for each position and records the time of the last change it contains and the addresses of the people who made changes since the previous version. It keeps every version from the last 24 hours, the last of each day for 30 days, and the last of each week before that, at most 100 versions and 100 million characters per model, and deletes the rest. A version is validated in the browser like everything else from the server, and restoring one is an ordinary change that everyone sees and that can be undone.
What the server handles. For accounts and online models, this includes the email addresses of accounts and of the people a model is shared with, who shares with whom and with which role, the content and name of each model saved online, the time, size, and order of changes, who made changes between two earlier versions, and, while people have a model open together, their selection and pointer, which are relayed and not stored. People with access to a model can read its whole content and each other's email addresses.
What the server stores. For an account: the email address and the time it was created. For a session: a hash of its token and three timestamps. For a model saved online: its content (the latest full copy, the changes after it, and the earlier versions), its name and number of nodes, its owner, the addresses it is shared with and their roles, and timestamps; until the next version is stored, the address and time of each change; and, for 24 hours, the random id of each browser tab that sent changes, with the number of its latest batch, so that a batch sent twice is applied once. For each user's folders: the folder names and which models the user has placed in each, visible only to that user. For each earlier version: the time of its last change and the addresses of the people who made changes in it. For each AI assistant that has registered: the name and redirect addresses it registered (removed after seven days if no user allows it), and, once a user has allowed it, a hash of its client secret if it has one, when it was allowed and last used, and hashes of its codes and tokens with their expiry. For rate limiting: counters keyed by an HMAC of the email address or client address, or by the account or grant id, deleted within two days. For error reports: the date, the error's type identifier and stack frames in toro4n's code, the browser's name and major version (such as Chrome 141, taken from the User-Agent header), a daily count per error and browser, and first and last timestamps, deleted after 30 days. For the count of created models: the date and the number of models created that day, with nothing about who created them. For email received at an @toro4n.com address: a copy of the message, deleted after 30 days. For a report of illegal content submitted at /report/: the name and email address provided (both optional when reporting child sexual abuse), the location of the reported content, the explanation, and the timestamp, kept for two years, even if the sender deletes their account. The application stores no client address and no full user agent, and Worker logging is disabled.
Deletion. Deleting a model online moves it to Recently deleted: it disappears from every list except Recently deleted, and every access check answers as if it did not exist, while its content, earlier versions, sharing, and folder placements are kept so that the owner can restore it. After 30 days, or at once with Delete forever, its content, earlier versions, sharing, and folder placements are deleted. Deleting a folder deletes only the folder, never the models in it. Deleting the account, under Account, deletes at once from the application's storage the account, its sessions, its folders, every model it owns (including those in Recently deleted), its access to models shared with it, and every AI assistant's access. The team models of a person who holds a seat on a Team are instead transferred to the team's manager, as described under Paid plans. Reports of illegal content are not deleted with the sender's account; they are kept for two years. A person removed from a model loses access to it at once; a copy they made while they had access remains theirs. The hosting provider keeps point-in-time recovery data for up to 30 days, and the backups below keep copies for up to 35 days.
Backups. Each model's Durable Object writes a full copy of its storage (the latest full copy, the changes after it, earlier versions, and the model's row with its owner and sharing) to a separate R2 bucket in the EU jurisdiction two minutes after changes stop, at most once an hour. Every night the Worker also writes a copy of the D1 tables for accounts, plans, reports of illegal content, models, Recently deleted, sharing, folders, and AI assistant grants, without sessions, sign-in codes, tokens, or rate-limit counters. Copies are JSON lines with a line count and checksum and are never restored when incomplete. Each copy is written under a new object key, never over an earlier copy. The bucket expires copies after 35 days, and a bucket lock rule prevents them from being deleted or overwritten for 30 days, including by the application. After writing the copies each night, the Worker restores the newest copy of the most recently backed-up model into a separate Durable Object and checks that its line count and checksum match the copy's and that the model can be read; any problem found by the night's backups and checks is emailed to the operator. Restores are started only by an object written to the bucket, which requires access to the Cloudflare account.
Email. toro4n emails sign-in codes, sharing notices, a notice when a person is given a seat on a Team, a single notice to the manager when an invoice is overdue, and receipts for reports of illegal content. It also emails the operator about any problem found by the night's backups, about errors reported for the first time in 30 days, and about every report of illegal content. All of these are sent from hello@toro4n.com through Cloudflare Email Sending (a Worker binding, so no mail password exists) and carry toro4n.com's own DKIM signature, with SPF and a DMARC policy of reject for the domain. Cloudflare receives the recipient's address and the message. A sharing notice names the address of the person who shared and contains a link to the model; no email toro4n sends contains anything from a model. Email sent to an @toro4n.com address is received by the Worker, which stores a copy for 30 days and forwards the message to the operator's Gmail account. The operator also uses Claude, Anthropic's AI assistant, to help read that email.
AI assistants
Connecting an assistant. A signed-in user can let AI assistants such as Claude or ChatGPT work with their models online. The assistant connects to https://toro4n.com/mcp with the Model Context Protocol (Streamable HTTP; every request is answered directly with JSON, and no state is kept between requests). Nothing is available there without a token that the user has granted, and models stored only in the browser can never be reached.
Granting access. Access is granted with the OAuth 2.1 authorization code flow and PKCE, S256 only. Assistants register themselves (RFC 7591) and find the endpoints through metadata (RFC 8414 and RFC 9728); the authorization response names the issuer in its iss parameter (RFC 9207). A redirect address must be https, http to the loopback interface, or an application's own scheme, and must match a registered address exactly. The authorization endpoint never redirects to another origin by itself, since anyone can register an assistant with any address. For an unknown assistant or address, the app shows that the link is invalid; any other error in the request is shown in the app together with the host it would return to, and is sent back to the assistant only when the user clicks Go back. The authorization request is passed to the app signed with HMAC-SHA-256 and expires after ten minutes. Only a signed-in user can approve it, with a POST from the app's own origin carrying the session cookie. The consent screen shows the name the assistant registered, which it chooses itself, together with the host it returns to.
Tokens. An authorization code is a 256-bit random value, valid once and for five minutes, and only for the assistant and PKCE verifier it was issued for, and for the redirect address it was issued for when the token request names one. An access token is a 256-bit random value, valid for one hour and accepted only in the Authorization header. A refresh token is a 256-bit random value that is replaced every time it is used and lapses after 30 days without use; a replaced refresh token presented again more than 30 seconds later revokes the whole grant. The server stores only SHA-256 hashes of codes, tokens, and optional client secrets. Assistants can revoke tokens (RFC 7009).
Scope. A token acts as the user who granted it, with that user's role in each model: it can list the models the user has access to, read them, create new models, change models the user may edit, list earlier versions, and restore one. It cannot delete or share models, change the account, or reach anything else. Changes go through the same validation and the same ordering as a person's changes, everyone with the model open sees them at once, and the model as it was before is stored as an earlier version first. Versions attribute the change to the user's address followed by “via” and the assistant's name.
Cross-origin access. /mcp, /oauth/register, /oauth/token, /oauth/revoke, and the metadata under /.well-known/ answer cross-origin requests from any origin (Access-Control-Allow-Origin: *), since they authenticate with bearer tokens or client credentials and never with the session cookie. Everything under /api/ keeps the same-origin checks described above, with two exceptions: the Google sign-in callback (and its page's script), which Google's redirect reaches from another site and which is bound to the browser by its cookie and the state; and Stripe's notifications to POST /api/billing/notify, which are authenticated by an HMAC-SHA-256 signature over the timestamp and the body.
Limits. Registrations are limited to 30 per hour and token requests to 300 per hour, both per client address; tool calls to 1,000 per hour per grant; an account to 50 assistants; and a single request to 2 million characters.
Where the content goes. What a tool returns, such as the content of a model, is received by the assistant and processed by the company that provides it, under that company's terms. toro4n's server never opens a connection to an AI provider; assistants call toro4n.
Revocation. Disconnect, under Account, deletes the grant and every code and token issued under it at once; deleting the account does the same for all its grants. The daily cleanup removes expired codes and tokens, grants without valid tokens, and registrations that no user approved within seven days.
Paid plans
Payment provider. Paid plans are sold by Lantz Hedström Consulting AB. Payments go through Stripe, which presents the checkout, processes the payment, calculates VAT and sales tax, validates VAT numbers, and issues invoices and receipts. Card numbers, billing addresses, and VAT numbers entered at checkout stay on Stripe's pages and are never sent to toro4n.
Checkout and customer portal. The app loads no script, frame, or other resource from Stripe, and the content security policy has no exception for Stripe. When a user buys a plan, the server creates a Checkout Session through Stripe's API and returns the address of Stripe's hosted checkout on checkout.stripe.com; the browser navigates there, and Stripe returns it to https://toro4n.com/app/?checkout=done. Billing and invoices navigates in the same way to a short-lived customer portal session that the server creates.
Server to Stripe. The server calls Stripe's API over TLS with an API key held as a Worker secret. For a checkout it sends the price, the currency (the one the buyer chose, a returning Stripe customer's earlier currency, or, by default, the currency for the buyer's country), the site's origin, the number of seats, the toro4n account's internal id, and, for a first purchase, the email address to fill in at checkout. For a Team paid by invoice, the currency is the one for the buyer's country, and the server also passes on the company details entered in the app (name, VAT number, address, invoice email address, and purchase order number), which it does not store. It never sends model content.
Notifications. Stripe reports changes to POST /api/billing/notify, signed with HMAC-SHA-256 over the timestamp and the body using a signing key held as a separate Worker secret. A notification without a valid signature, or whose timestamp is more than five minutes from the server's clock, is refused. A notification is only a hint: the server reads the subscription's current state from Stripe's API and stores that, and each notification is applied once.
What the server stores. For each subscription: Stripe's subscription and customer ids, the email address of the person who manages it, the plan, billing interval, status, number of seats, renewal and end dates, whether it is paid by invoice, when a payment became overdue, and two timestamps; for a Team, the email addresses that hold its seats; and, for 30 days, the ids of the notifications handled. Deleting the account cancels its subscriptions at Stripe first and then deletes these records; Stripe keeps its own records of transactions and invoices as the law requires.
Enforcement. Plan limits are enforced by the server, both in the app and for AI assistants. On the Free plan, saving a fourth model online is refused, and Version history lists the last seven days. A Free owner's three earliest-created models online stay editable; any others are served view-only to everyone, the owner included, and the live connection applies the same rule. A plan whose payment is overdue keeps working for 14 days, then counts as Free until it is paid. A model is never deleted because a plan ended.
Team models. A model put online by a person who holds a seat on a Team is recorded as the team's. The team's manager has the owner's role in it (open, share, delete, restore, hand over) without being on its sharing list. When a seat is removed, or its holder deletes their account, that person's team models, including those in Recently deleted, are transferred to the manager and the person loses access to them; the people they are shared with keep their access. A team model can be handed over only to someone in the same team. Models a person had before taking a seat stay theirs. When the manager's account is deleted, the team's models stay with their owners and no longer belong to a team.
Browser-enforced policy
Response headers. Every response from https://toro4n.com, including redirects and API responses, carries the headers below. The server and the automated browser tests use the same definition, so the tests run under the production policy.
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' wss://toro4n.com; manifest-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Cross-Origin-Opener-Policy: same-origin
Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()
What the policy implies. default-src 'none', connect-src 'self' wss://toro4n.com, and form-action 'none' mean that no page served from toro4n.com (the app, the start page, or any other page on the site) can open a connection to any other origin: fetch, images, fonts, frames, and form submissions to other hosts are blocked by the browser, even for injected script. The wss entry is the app's own host, listed because not every browser includes WebSocket connections in 'self'. script-src 'self' excludes inline script and eval. frame-ancestors 'none' and X-Frame-Options prevent framing. The one relaxation is style-src-attr 'unsafe-inline', required for style attributes on SVG elements; inline style elements and stylesheets from other origins remain blocked.
What it does not cover. A content security policy does not restrict top-level navigation, so script injected into the page could still carry data away by navigating it to another site. Injection is therefore also prevented where input is read and where output is written, as described next.
Untrusted input
Schema validation. Imported JSON, pasted or read from a file, is parsed and validated against a strict schema before it reaches application state: unknown fields are dropped, and input that fails validation is rejected. The results of the two Neo4j queries in Import are read as data (never evaluated) and turned into a model before the same validation; toro4n never connects to the database. Pasted plain text that is not a model is read as a list of names: it is split on line breaks and tabs, and each name becomes a node label before the same validation. Files are never read this way. Models read back from IndexedDB, and models received from the server, pass through the same validation.
Color values. Colors from a model are written into SVG attribute values, so each must match an allowlist: a hex code, a single word such as a color name, or a color function (rgb, hsl, hwb, lab, lch, oklab, oklch, color) whose arguments contain only letters, digits, spaces, and . , % / + -. Anything else, such as url(...) or a string containing quotes or markup, is replaced by the default color.
Output encoding. The canvas and the SVG export are rendered by React, which escapes text and attribute values; the parts of the SVG export assembled as strings are XML-escaped. Note text is stored as plain text with markers for bold and italic and is HTML-escaped before it enters the editor, and pasted content is inserted as text/plain. In Cypher export, identifiers are backtick-quoted where they are not plain names, and string values escaped. Text in emails is HTML-escaped. No code is ever evaluated from strings: there is no eval, Zod's compiled checks are switched off (jitless) so the Function constructor is never called, and there are no remote imports.
Verification
Release gate. A release is published only if linting, type checking, unit tests, and browser tests (Playwright: Chromium, and WebKit and Firefox for what differs between browsers, including that loading the app causes no policy violation) pass, and npm audit reports no known vulnerabilities in the runtime dependencies. The unit tests run the server code against a real SQLite database: sign-in, rate limits, access control, validation, and concurrent changes from several simulated clients. The browser tests run against the same server code in the hosting provider's local runtime, under the production headers. One of them drives the app through editing and all four export formats and fails on any request outside the app's origin or any policy violation. Another asserts that a visitor who does not sign in causes no request under /api/ in normal use other than GET /api/session and the empty POST to /api/model-counts. Others sign in two users, share a model, and assert that a removed or uninvited user is refused, and one connects an AI assistant through the whole authorization flow and asserts that its change appears live and that Disconnect stops it. Unit tests cover PKCE, single-use codes, refresh token rotation and reuse, redirect address rules, and tampered requests. Unit tests also assert that a person without access receives the same answer as for a model that does not exist, for every route and for the live connection.
Dependencies. The runtime dependencies of the app are React, Zustand, Immer, zundo, Zod, and idb-keyval, bundled at build time, and the self-hosted Geist font. The server code depends on Zod, and on React through layout code it shares with the app; it renders nothing. Nothing is loaded from a CDN.
Verify it yourself. To check that a visitor who does not sign in sends no model data, open https://toro4n.com/app/ with the browser's developer tools on the Network tab and use the app without signing in: apart from the initial load and the same-origin script files for dialogs, the only requests are GET /api/session, the sample model if you open it, an empty POST to /api/model-counts the first time you change a new model, and a report to POST /api/errors if an uncaught error happens. The headers can be read with curl -sI https://toro4n.com/app/. In Chrome or Edge, the models kept in the browser are under Application > IndexedDB, and the localStorage and sessionStorage keys described above are under Application > Local storage and Session storage.
Hosting and residual risk
Hosting. The app, its API, and its storage run on Cloudflare: a Worker with static assets, a D1 database for accounts and the list of models, one Durable Object per online model for its content, and an R2 bucket for backups. The database, the Durable Objects, and the bucket are created with the EU jurisdiction restriction, so their data is stored and processed only within the EU. The Worker runs, and TLS terminates, at the Cloudflare data center nearest the user, which may be outside the EU. Like any host, Cloudflare can observe the IP address, time, and user agent of requests. Worker logging is disabled. Cloudflare adds its own NEL and Report-To headers, which let browsers report failed connections to Cloudflare; those reports contain no page content.
Access by the operator. Models saved online, and their backups, are protected by access control, TLS, and the hosting provider's encryption at rest, not by end-to-end encryption. The operator (Lantz Hedström Consulting AB), anyone else with administrative access to the hosting account, and the hosting provider can technically read them.
Email and Google accounts. Anyone who can read the sign-in codes sent to an address, or who can sign in to the Google account for a gmail.com, googlemail.com, or Google Workspace address, can sign in to toro4n as that account and act with its full access: open and change its models and those shared with it within the role given, share and delete the models it owns, connect AI assistants, and delete the account. Protect the mailbox and the Google account accordingly.
AI assistants. An assistant the user allows can read every model online that the user has access to, including models shared with the user, and change those the user may edit; what it reads is processed by its provider. Its name on the consent screen is what it says about itself; the host it returns to is the reliable part. Allow only assistants you would trust with the models, and disconnect those you no longer use.
Integrity of the delivered code. As with any web application, the browser receives the code on each visit, so the protections described above depend on the integrity of toro4n.com and its host.
The user's device. Data at rest in the browser is as protected as the browser profile. The app does not encrypt IndexedDB, localStorage, or sessionStorage, which can hold model content as described above. Anyone with access to the browser profile, or a browser extension with access to the page, can read them, and deleted data may remain on disk until the browser compacts its storage. Use disk encryption and managed browser profiles where that matters.
Exported files. A saved or exported file is a copy outside the app's control. A saved file always includes hidden layers, and an export includes them unless “Visible layers only” is checked. Simple mode is a display setting of the app, not part of the model: on screen and in SVG and PNG it hides properties and notes, but JSON still contains both, the full Cypher script contains the properties, and the constraints-only script names the keys of Unique and Required properties. JSON files carry the model's creation and modification times, but no user identity.