Maowl Open Maowl

Security architecture

This page is for readers who need to assess Maowl before it is used with sensitive material. It describes how the service at https://maowl.app 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 Maowl 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://maowl.app/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); and if the visitor followed the unsubscribe link in a notification email and confirms, it sends the link's sealed token to POST /api/notifications/unsubscribe; none of these carry model data. https://maowl.app/ 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.maowl.app and plain http to https://maowl.app.

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 maowl.app (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; for a visitor who confirms an email's unsubscribe link, the one request 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, Compare, Git, the comparison panel, 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 kinds of navigation leave the app's origin: Google sign-in, Microsoft sign-in, single sign-on, and connecting GitHub or GitLab, each in a separate window that the server redirects to the provider and that the provider 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 maowl.app 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 foreign key 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 rather than copied: 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. Users authenticate with a six-digit code sent to their email address, through Google, through Microsoft, or through the single sign-on of a Team that has verified the address's domain. 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@maowl.app, 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-maowl_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.

Sign-in with Microsoft. Microsoft sign-in uses the same flow, window, and checks as Google sign-in, with Microsoft's common endpoint for work, school, and personal accounts, the cookie __Host-maowl_oidc, and the scopes openid and email. The issuer must be https://login.microsoftonline.com/{tid}/v2.0 for the tenant (tid) named in the token. Microsoft is accepted as authoritative for the email address only for a personal Microsoft account, when the token's xms_edov claim states that the tenant has verified the address's domain, or when the address's Team has pinned single sign-on to that tenant. Any other address must use a code. Nothing else from the Microsoft account is stored.

Single sign-on. A Team's manager can choose an identity provider for the Team's verified domains: Microsoft, Google, or the company's own OpenID Connect provider, given by an https issuer URL, a client ID, and a client secret. The secret is stored encrypted (authenticated encryption in GCM mode with a 256-bit key derived with HKDF-SHA-256 from the server's secret), is sent only to the provider's token endpoint, and is never returned to the app. The provider's endpoints are read from /.well-known/openid-configuration under the issuer, whose issuer value must match exactly; only https addresses with a host name are called, on the default port, without following redirects, with a ten-second timeout and a size limit on replies. The flow and checks are those of Google sign-in, with the cookie __Host-maowl_oidc, and a Team's provider is accepted only for addresses at that Team's own verified domains, never when email_verified is false. When the Team requires single sign-on, the server refuses emailed codes for those addresses, including codes requested earlier, and refuses Google or Microsoft sign-ins other than the chosen provider; the Team's manager is exempt, so that a faulty provider setting can always be corrected. SAML is not supported.

Session. A successful sign-in sets one cookie, __Host-maowl_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. The same checks enforce the plan limits described under Paid plans: if a model has more editors than the editor limit allows, the most recently invited editors beyond the limit are served view-only, and when an owner's plan keeps only their three earliest-created models online editable, their other models online are served view-only to everyone, the owner included. 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 table, foreign key, 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.

Comments and review status. Everyone with access to a model online, including people who can only view it, can read and write comment threads on it. A thread is anchored to the whole model, a table, a column (by the table's id and the column's name, updated when the app that renames the column reports it), a foreign key, or a note. Comments are stored in the D1 database beside the model, never in the model itself, so they do not appear in its content, versions, or exports. A comment is plain text of at most 5,000 characters with control characters removed; the app renders it as text, never as markup. Only its author can edit or delete a comment; anyone with access can resolve or reopen a thread. People mentioned with @ receive a notification only if they have access to the model. Each model also has a review status (Draft, Ready for review, Approved, Changes requested, or Changed since approval). Draft and Ready for review are set by people who can edit; Approved and Changes requested by the reviewers the owner names, or, when none are named, by people who can edit. Approving records who approved, when, and the position in the order of changes; the model's Durable Object sets Changed since approval when a later change is stored. Writes are rate-limited per account, and each model has limits on threads and on comments per thread.

Notifications. Mentions, replies in a thread a person is in, and review status changes are queued in the D1 database and sent every five minutes as at most one email per model and recipient in any ten minutes. These emails name the person who acted and link to the model, and contain neither the model's name nor any comment text. Each email has a one-click unsubscribe link (also offered through the List-Unsubscribe and List-Unsubscribe-Post headers) whose token is the recipient's address and the model's id sealed with authenticated 256-bit encryption under a key derived from the server's secret, so it can be neither read nor forged; following it in the app asks for confirmation before anything changes. The opt-outs, per model or for all models, are kept until the account is deleted, and can be changed under Account.

Slack and Microsoft Teams. The owner of a model online can connect one incoming-webhook address, in Share. The server accepts only https addresses on the default port without credentials, on hooks.slack.com (paths /services/, /workflows/, and /triggers/), *.webhook.office.com (/webhookb2/), *.*.logic.azure.com (/workflows/), and *.environment.api.powerplatform.com (/powerautomate/). The address is stored with authenticated 256-bit encryption under a key derived from the server's secret and bound to the model's id; it is never returned to the app, which shows only its host and last four characters. When a version is saved, comments are added, or the review status changes, the Worker posts one bundled message there, at most once per model in any ten minutes, containing the model's name, the email address of the person who acted, what happened, and a link to the model; never comment text or anything else from the model. These posts, and a test message the owner can send, are the only requests the server makes to a third-party address on a user's behalf. They are sent with a ten-second timeout, redirects are not followed, and the response's status is recorded and shown to the owner.

Live embeds. The owner of a model online (or the manager of its team) can turn on an embed in Share; it is off by default, and for a team model only when the team allows embeds. The server then issues an address of the form https://maowl.app/embed/<token>.svg (and .png), where the token is 43 random characters (258 bits). The server finds the model by the token's SHA-256 hash and keeps the token itself only with authenticated 256-bit encryption under a key derived from the server's secret, so the owner can copy it again; other people with access learn only that an embed is on. Anyone who has the address can fetch the picture without signing in. The SVG is drawn by the server from the stored model on every request, from the visible layers only, with the same renderer and output encoding as the app's SVG export; the PNG is drawn by the app of a person who may edit the model while it is open, and the server accepts it only from such a person, only when it starts with a valid PNG signature and header, at most 1.5 MB and 16,384 pixels per side. The model's content as data is never served there. Embed responses carry Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; img-src data:; frame-ancestors 'none'; sandbox, Cross-Origin-Resource-Policy: cross-origin, Access-Control-Allow-Origin: *, X-Content-Type-Options: nosniff, X-Robots-Tag: noindex, and Cache-Control: no-cache, and no X-Frame-Options, so other sites can show the picture but nothing in it can run or load anything. Every request is checked again, so turning the embed off or getting a new address stops the old address at once; deleting the model removes its embed, and restoring it does not bring the embed back. Requests per address are limited to 600 a minute, and stored PNG pictures to 120 per model an hour.

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, the time it was created, and the time and method of its latest sign-in. For a Team's controls and audit log: what is described under Paid plans. 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 tables, 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 comments: each thread's anchor and author, each comment's text, author, and times, and who resolved a thread and when. For each model's review status: the status, who set it and when, who approved and when, the named reviewers, and their approvals. For notifications: queued events (recipient, kind, the address of the person who acted, and time) until they are sent or for at most seven days, the time of the last message per model and recipient for ten minutes, each person's opt-outs, and, for a model with a connected channel, the encrypted webhook address, the address of the owner who connected it, and the time and status of the last post. For a model with a live embed: the hash and the encrypted token of its address, who turned it on and when, and the latest PNG picture with its time. 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 Maowl'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 @maowl.app 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) with their comments, review status, and channel, its access to models shared with it, its comments, approvals, queued notifications, and notification settings, 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. Deleting a model for good also deletes its comments, review status, queued notifications, opt-outs, and channel.

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, Team domains, settings, and audit logs (with the client secret still encrypted), reports of illegal content, models, Recently deleted, sharing, folders, AI assistant grants, comments, review statuses and approvals, notification opt-outs, and encrypted channel addresses, 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. Maowl emails sign-in codes, sharing notices, the notifications about comments and review status described above, 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@maowl.app through Cloudflare Email Sending (a Worker binding, so no mail password exists) and carry maowl.app'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 Maowl sends contains anything from a model. Email sent to an @maowl.app 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://maowl.app/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. On every call, a token acts as the user who granted it, and in each model it has the role that user is currently served, which reflects the plan limits described under Paid plans: it can list the models the user has access to, read them, create new models, change models where that role allows editing, 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 none of them uses the session cookie: /mcp, /oauth/token, and /oauth/revoke authenticate with bearer tokens or client credentials, while registration and the metadata are public. The metadata consists of the OAuth discovery documents and the MCP server cards (/.well-known/mcp/server-card.json and /.well-known/mcp.json), which describe the server and list its tools; none of it contains account or model data. Everything under /api/ keeps the same-origin checks described above, with two exceptions: the callbacks of Google sign-in, Microsoft sign-in, single sign-on, and the Git link (and their pages' scripts), which the provider's redirect reaches from another site and which are 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. Whatever 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. For the assistants, Maowl's server never opens a connection to an AI provider; assistants call Maowl. The one connection Maowl's server makes to an AI provider is the AI design review, described next.

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 Maowl.

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://maowl.app/app/?checkout=done. Billing and invoices works the same way: the browser navigates 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 site's origin, the number of seats, the Maowl account's internal id, and, for a first purchase, the email address to fill in at checkout. The currency is EUR or USD. A returning Stripe customer keeps their earlier currency; otherwise it is the one the buyer chose or, by default, EUR for a buyer in Europe (the UK included) and USD elsewhere. For a Team paid by invoice, the currency always follows that default by 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. On every plan, at most two people besides the owner can edit a model; in a team model, editors who hold a seat on that Team do not count toward the two. Inviting an editor beyond the limit, or changing a viewer to an editor once the limit is reached, is refused. Editors are ranked by when they were invited, and a previous owner counts as invited when the model was saved online. If a model has more editors than the limit allows, for example after ownership is transferred, the most recently invited editors beyond the limit are served view-only until an earlier editor is removed or changed to a viewer. 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 controls. The manager of a running Team can claim up to ten email domains for the company. A domain is verified when a TXT record at _maowl-challenge.<domain> holds the verification string shown to the manager, which contains a 192-bit random token, as found through Cloudflare's DNS over HTTPS; a domain can be verified by one Team only, and public email domains such as gmail.com and outlook.com cannot be claimed. Every night the record is looked up again; a failed lookup changes nothing, and a domain whose record has been missing for seven days stops being verified. For its verified domains, the Team can choose single sign-on (described under Accounts and online models) and require it, and can let people at those domains take a free seat when they sign in. The manager can also limit sharing of team models to people with a seat on the Team and addresses at its verified domains, which the server enforces when a person is added to a team model; turn off AI assistants for everyone with a seat, which removes their assistants' grants at once and refuses new ones; choose whether Share suggests Can edit or Can view for team models; allow live embeds of team models (off unless the manager turns it on; turning it off removes every embed of the team's models at once); see when each person in the Team last signed in and how; and end all of a person's sessions, which also closes their live connections. Everything here applies only while the Team is running.

Audit log. For a running Team, the server records sign-ins by people in the Team or at its verified domains; the creation, deletion, restoration, permanent deletion, sharing, changes of access, and changes of owner of team models; seats given, removed, or bought; domains added, verified, lost, or removed; changes to single sign-on and to the controls; sessions ended by the manager; AI assistants connected or disconnected by people in the Team; and live embeds of team models turned on, given a new address, or turned off. Each entry holds the time, the email address of the person who acted, the action, its target (an email address, a model id, or a domain), and details such as a model's name, the role given, or the sign-in method; never a model's content or the client secret. Only the Team's manager can read the log, filter it by person, action, and date, and download it as CSV, in which a cell that would start a spreadsheet formula is prefixed with an apostrophe. Entries are deleted after a year.

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. A seat gives no access by itself: a team model is shared with named people like any other model, and a seat holder does not count toward the editor limit in that Team's models. 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.

AI design review

What is sent. Design checks run in the browser and send nothing. The AI design review runs only when a signed-in user clicks Review design; for a model stored only in the browser, the user first confirms what will be sent. The app then sends POST /api/design-review with the model's structure only: table and column names, data types, Required, Unique, generated columns, primary keys, indexes, CHECK rules, defaults, descriptions, foreign key columns and actions, enums, the naming rule, the text of the notes, and the design checks' findings. Positions, styles, layers, example values, comments, versions, and anything about the account are never sent. The server validates the request against a strict schema (at most 300 tables, 200 characters per name, 200,000 characters in all) and rejects anything else.

The provider. The server sends that structure, written as DBML-like text, to Anthropic's Messages API (api.anthropic.com) over HTTPS with an API key held as a Cloudflare secret (ANTHROPIC_API_KEY), and asks for an answer that follows a JSON schema. The model is a setting (ANTHROPIC_MODEL, Claude Sonnet 5.5 by default). The request has a 120-second timeout and the reply is read up to 400,000 characters. The prompt tells the model that everything in the design is data and that instructions in it must not be followed. Anthropic processes the request under its commercial terms, which exclude training on it. When the key isn't set, the review isn't offered and the endpoint answers 404.

The answer. Every suggestion is validated with the same schema on the server, and only suggestions that name tables and columns present in the request are returned; a proposed change that names anything else is dropped. Nothing is stored: neither the structure nor the answer. Applying a suggestion in the app is an ordinary change by the user, one undo step, validated like any other; for a model online, a version is stored right after it.

Limits and controls. Reviews are limited to 10 per hour and 30 per day per account. A Team's manager can turn the review off for everyone in the team (Account > Team > Controls), after which the endpoint answers 403. Each review by a team member is written to the team's audit log with the number of tables and suggestions, never their content.

Git link

Connecting GitHub or GitLab. The owner of a model online can keep it in a GitHub or GitLab repository (File > Git…). The connection uses the OAuth 2 authorization code flow with PKCE (S256) in a separate window: GET /api/git/{github|gitlab}/connect redirects to the provider, and the provider returns to /api/git/{provider}/callback. The state and the PKCE verifier are HMAC-SHA-256 values derived with the server's secret from a random key held in an HttpOnly, Secure, SameSite=Lax cookie (__Host-maowl_git) that lasts ten minutes, names the account, and carries its own HMAC so it cannot be altered; the session cookie is not needed on the callback. The scopes requested are repo on GitHub and api on GitLab, the narrowest each provider offers that can create a branch and open a pull or merge request. A company's own GitLab can be used (GITLAB_BASE_URL, https only).

GitHub and GitLab tokens. The access token (and GitLab's refresh token; GitLab's access tokens expire after two hours and are renewed by the server) is encrypted on the server with authenticated encryption, under a key derived from the server's secret for this purpose only,, and stored in the database (git_connections), never in the nightly backup. It is never sent to the browser. Disconnect deletes it and revokes it at the provider; deleting the account deletes it.

What is sent where. Only the server talks to GitHub or GitLab; the app's own requests stay on maowl.app. Save to Git sends the model's JSON, its CREATE TABLE script, and, when the repository already holds the model, a change script from that version, to the provider, where they become one commit on a new branch named after the model and the time and a pull or merge request into the branch the owner chose. Maowl never commits to that branch itself. The server validates the model like any model sent to it and builds every file path itself from the chosen folder and the model's name. Update from Git reads the model file from the chosen branch and shows it in Compare before anything changes. Repository, branch, and folder are stored with the model (git_links). Connecting, disconnecting, and saving are written to the audit log of the person's Team. Requests to the providers time out after 15 seconds and replies are read up to 6 million characters.

Browser-enforced policy

Response headers. Every response from https://maowl.app, 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://maowl.app; 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://maowl.app, and form-action 'none' mean that no page served from maowl.app (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. Pictures served at /embed/ have their own policy, described under Live embeds.

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, and of the catalog query for a running table database, are read as data (never evaluated) and turned into a model before the same validation; Maowl never connects to the database, and the catalog queries read only table definitions, never rows. Pasted or dropped table definitions (CREATE TABLE and ALTER TABLE statements) are parsed as text into a model before the same validation; nothing in them is executed. Pasted plain text that is neither a model nor table definitions is read as a list of names: it is split on line breaks and tabs, and each name becomes a table name 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 SQL export, table and column names are quoted in the chosen database's syntax where they are not plain names, and a data type is kept as written only when it has the form of a type. 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://maowl.app/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://maowl.app/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, Google, Microsoft, and company accounts. Anyone who can read the sign-in codes sent to an address, who can sign in to the Google account for a gmail.com, googlemail.com, or Google Workspace address, who can sign in to the Microsoft account Microsoft vouches for, or who controls the identity provider and DNS of a Team's verified domain, can sign in to Maowl 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, the Google or Microsoft account, and the company's identity provider and DNS 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 can currently edit; what it reads is processed by its provider. The consent screen shows the name the assistant chose for itself and the host it returns to, which identifies where the authorization code is sent, not who operates the assistant. Allow only assistants you would trust with your models, and disconnect those you no longer use.

Integrity of the delivered code. As with any web application, the browser receives the code anew on each visit, so the protections described above depend on the integrity of maowl.app 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 beyond 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 columns and notes, but JSON still contains both, the SQL and full Cypher scripts contain the columns, and the constraints-only Cypher script names the Unique and Required columns. JSON files carry the model's creation and modification times, but no user identity.