Skip to content

Security model

Two credentials open a room: a session JWT for an account and a share token for a link. The owner key that used to be the third was deleted (ADR-0076), with no migration for keys already issued.

This page describes the current architecture. It is not a promise of end-to-end encryption.

Credential Shape Grants Does not grant
Session JWT Authorization: Bearer <jwt> on HTTP, the join payload on a socket Read and write the workspace rooms the token’s organization owns, create and revoke share links, upload and delete attachments, delete the workspace A workspace another organization recorded, a projection room, an admin route
Workspace editor token ?share=<token> Read and write on the workspace document and its presence Managing links, deleting the workspace, any account route
Workspace viewer token ?share=<token> Read-only access to the workspace document Any write, link management
Board or card viewer link ?share=<token> Read-only access to a live projection of that board or card The workspace index or id; any write
Selection viewer link ?share=<token> Read-only access to a live projection of the selected cards on one board, the geometry of the sections those cards occupy, and the titles of cards they reference, marked not shared The unselected cards, the workspace index or id, the selected ids, any write
Board or card editor link ?share=<token> Editing a draft in its own share-<token> room, then publishing that draft Writing the workspace directly, managing links
Internal worker tokens SUITE_PROJECTION_TOKEN (legacy SUITE_INTERNAL_TOKEN) and SUITE_SOURCE_TOKEN Server-to-server access for the projection worker: projection rooms only, and a workspace room only while it has an active editor share Anything user-facing; they must never reach a browser or a log
Workspace purge token SUITE_RELAY_PURGE_TOKEN, presented by the control plane as a bearer token Delete the workspace named in the path, with {"confirm":"<id>"} in the body. Nothing else Every other workspace route, /api/admin/*, any read

A share token is random and stored only as a SHA-256 hash. The relay resolves it per room as read or write. A share token can never mint or revoke another link, and it is not a session: a management route answers 401 for one. When a worker token is unset, each server start generates a random one, which only matters if a worker reconnects.

The app reads explicit capability flags rather than one read-only switch: canEditContent, canEditLayout, canCreateContent, canCreateBoards, canManageBoards, canManageSchema, canManageTrash, canPublish and canManageLinks. A workspace editor token gets the live workspace surface, meaning boards, types, tags and trash, with no share, export or account actions. A board draft can create cards, sections and edges and edit what it publishes; a card draft edits only its card. Neither can create boards, schema, trash or links.

The control plane mints a session JWT through Better Auth’s jwt() plugin and publishes the key set at GET <SUITE_AUTH_URL>/api/auth/jwks. The relay verifies the token locally. It holds no identity table, no Postgres credential, and makes no control-plane call per message.

The token is Ed25519, its issuer and audience are both SUITE_AUTH_URL, and every claim is read server-side when it is minted:

Claim Source What the relay does with it
sub the session’s user Audit context only; the relay never looks an account up
org session.activeOrganizationId Compared with the organization the relay recorded for the workspace
role the member row Sets the seat label and the app’s capability flags
tier the organization’s subscription row A token with no tier is refused
limits the same row Recorded per workspace, so a save that carries no token still meets the caps

The lifetime is 300 seconds (RELAY_TOKEN_TTL_SECONDS in services/auth/src/identity/relayToken.ts). That number is the revocation window and it is published on purpose: a seat removed, a role lowered or a subscription cancelled takes effect at the next mint, so a revoked member keeps a working socket for at most five minutes. The client asks GET /api/auth/token for a fresh token and presents it on the next join; a live socket is not re-authenticated mid-session.

What the relay checks, and what it refuses

Section titled “What the relay checks, and what it refuses”

apps/collab-server/src/http/sessionToken.ts verifies the signature, iss, aud, exp and iat, then narrows the payload to the five claims above. Anything else in the token is ignored. A token that fails any step is a refusal: the join closes, and nothing is retried as a share token or as a second credential. apps/collab-server/src/sync/seatGate.ts then answers from the claims alone, with no database read. It refuses a session whose org is empty and a token whose tier is empty, and it treats a share visitor as a capability rather than a seat.

The relay fetches the JWKS at most once per five minutes of use and reuses it in between, so a busy relay does not call the control plane. Two events force a refetch: the cache ageing out, and a token naming a key id the cache has never seen. The second is subject to a five-second cooldown, which is what keeps a forged kid from turning every join into a control-plane request; the cost is that a newly rotated key refuses joins for at most those five seconds. Clock tolerance is 30 seconds in each direction, because a relay whose clock has drifted should not refuse a valid token. The tolerance is small on purpose: it adds to the TTL, so a generous value would widen the revocation window by the same amount.

The relay fetches nothing at boot. A control plane that is down means no account can join; a control plane that comes back is picked up by the next fetch, without restarting the relay.

A workspace with no account is local-only. It opens, edits, exports and deletes from the browser with no prompt and no credential, and it never reaches a relay. The socket answers Authentication failed because a room credential is a session token, and a token names an organization.

This is a deliberate narrowing, not a gap left open. The owner key used to authorise a local room by being a bearer secret nobody else had. Deleting it removed the only attribution such a connection had, and inventing a second credential for it would have recreated the thing that was deleted. Sync also costs a seat, so it needs an owner to charge.

Better Auth’s anonymous sessions are gone for the same reason. A session that could be created without a person is a session nobody can be charged for and nobody can be held to, and the browser never needed one: the document is in IndexedDB, and the workspace id in the URL is the whole of its identity.

The local-first promise survives intact. Everything a reader can do alone still works with no account, and the document is theirs to export. What changes is the second browser. A workspace becomes visible to another machine at the moment it is claimed by a signed-in account, which is also the moment it can be paid for. The claim is the first join: the relay records the organization on the workspace_orgs row and the ids do not change, so a link shared point-to-point before sign-up still resolves afterwards.

A self-hosted instance that runs only the relay therefore has no sync either. It is the same rule seen from the other side: the relay has no way to decide who may join, so it refuses everyone. A self-host that wants collaboration runs the control plane beside it, which compose.yaml already does.

The control plane’s workspaces row is the authority: workspaces.id is the document id, and workspaces.organization_id is the tenant (ADR-0065). The relay holds no Postgres credential, so it keeps its own copy in the workspace_orgs table of the file SUITE_WORKSPACES_DB names, and it writes that row when the first verified session opens the workspace. The first claim wins. A later join from another organization is refused with 403 This workspace belongs to a different organization., and the recorded row is untouched.

A named limit: an unclaimed workspace’s id is its credential

Section titled “A named limit: an unclaimed workspace’s id is its credential”

A workspace nobody has opened on this relay is claimable by the first account that names its id in a verified session. That is the first-claim rule the owner key had, and it is what ADR-0076 decided when it deleted the key: a workspace’s identity is its document id.

Say it plainly, because the consequence is not obvious: while a workspace is unclaimed, holding its id is the same thing as owning it, and the relay cannot tell the browser that created it from any other. Three facts bound the exposure rather than removing it:

  • An id is a crypto.randomUUID(), so it is not guessable, and it reaches a second person only because the first one pasted it somewhere.
  • A share link never carries it. A published page is /s/<slug>, and the slug is the share token, which is stored hashed and can be revoked. So handing somebody a link hands them the page, not the workspace.
  • A claim needs a session whose organization the account administers with canManageLinks, and the refusal for an already-claimed workspace is tested (workspace-owned-elsewhere).

An unclaimed workspace is therefore as private as its id is. Treat an unopened workspace as unclaimed until its owner opens it, and do not paste a workspace URL into a channel you would not paste a document into. ADR-0084 records the change that closes this: a key the browser holds, with no ceremony for the reader.

Share links still work for a visitor with no account: resolving a link stays public, because the token is the credential the recipient holds. The first open exchanges the token for a short-lived HttpOnly session cookie, and later requests carry the session, which has a 15-minute TTL, is hashed at rest, and is deleted when the share is revoked. A link never becomes a seat and is never asked for one; it is bounded by its own expiry and revocation. Hosted links default to 30 days.

A published page has one address: /s/<slug>?share=<token>. The slug is derived once, when the link is created, from the name of the card or board it publishes, or from the scope word when there is no name; it is unique inside its workspace and never reused, revocation included, so a copied address never comes to mean a different page. The slug is not a credential: it resolves nothing on its own, and the token, or the session the token was exchanged for, remains the only thing that opens the projection. The address keeps its slug for good, so a reader can reload the page, or come back to the address the author copied, with no account; what they present is the session cookie, which slides while the page is used and dies with the link.

A leaked share link is fixed by revoking that link. Revocation stops new joins, closes the open sockets that hold it, and removes the projected draft. It does not change the workspace, and it does not close the account that is editing the workspace. A reader who returns to a revoked address is told the page was unpublished; the shell never renders an empty projection in its place.

What publishing costs in exposure: the card, and every link inside it, becomes readable by anyone holding the address, and the author is told that in the share dialog before the link exists.

Documents are plaintext. The document store holds the full Loro snapshot of every workspace, and the operator can read all of it. An SVG or HTML upload is refused because a served attachment must never be executable content.

Share tokens are stored as SHA-256 hashes, so a stolen database does not directly hand out working credentials. Session tokens are not stored at all: the relay holds a public key and verifies a signature, so a stolen relay database yields nothing that opens a room. The licence rows beside a workspace are bookkeeping, not credentials.

A live socket keeps its credential in memory. The revoke path has to decide which open sockets belong to a share it just invalidated, so the patched sync server keeps each socket’s credential per joined room for as long as the socket is connected, and clears it on leave and on disconnect (patches/loro-websocket@0.6.2.patch). That does not expose a session to the operator; it does mean “not stored” should not be read as “never in memory”.

Attribution stops at the link. An account’s join is attributable to the sub in its token for as long as the relay logs it, and presence (names, pointers, carets) is ephemeral and never written to the document, so every editor using a link shares that link’s identity. Metadata still exists: workspace ids, board and card ids in share rows, creation, last-use and revocation timestamps, the organization id recorded per workspace, and the IP addresses seen by the server or its proxy. The last-used timestamp is coarse, at most one write a minute, and says a link was opened, not by whom.

Backups are unencrypted. All six SQLite stores and .suite exports contain the same plaintext documents, every uploaded image, and the licence and quota bookkeeping. Suite does not encrypt them.

Anyone who receives a link can forward, copy or screenshot the workspace. Revoking a link cannot take back what was already read.

A share token in a URL can leak through access logs, analytics, screenshots or browser sync. Serve the app with a restrictive Referrer-Policy and do not log query strings.

A leaked session token is a working credential for up to five minutes, and it is as powerful as the person it was issued to: it syncs, it mints links, and it deletes the workspace. It is not bound to a device. The fixes are the TTL above, TLS everywhere, a restrictive CSP so XSS cannot read local storage or a token in memory, and server-side session revocation in the control plane, which stops the next mint.

A link image is fetched from a third party by every reader. An image node stores a URL, so the viewer’s browser contacts that host, which learns the viewer’s IP address and when the card was opened. Each link image renders with referrerpolicy="no-referrer", loading="lazy" and decoding="async", so the workspace URL and share token are not sent along, and the URL itself is document content, so a share reader can see it. The CSP allows https: in img-src for this reason; an attachment keeps readers off third-party hosts.

/metrics and /api/admin/* answer an operator session, and both routes answer 404 when the relay has no control plane to verify one against. An operator is an account whose address is on SUITE_ADMIN_EMAILS:

Caller Answer
No session, or a session this deployment cannot verify 401
A valid session for an account that is not on the list 403 forbidden, logged with the path
An account on the list 200, with both scopes

Two things follow from using a session rather than an environment token. Revoking an operator is a restart, not a rotation of a secret that may be cached in a script or a shell history. And the token is the same one the canvas already holds, minted from SUITE_ADMIN_EMAILS on the control plane and read here as an operator claim, so the answer cannot differ between the two processes.

The claim is not a role. role is the account’s role inside the organization the session acts in, so an organization owner is a perfectly valid session that an operator route still refuses: owning a tenant is not operating the deployment.

read cannot mutate, and the router keeps that split even though every operator holds both scopes today: it describes the blast radius a narrower role would arrive with.

A scrape timer has no session, so SUITE_METRICS_LOCAL=1 lets a request from a loopback address read /metrics with no credential. That opens one route for one method, and a request that arrived through the proxy is never a loopback request.

Every mutating admin request writes one audit row to the relay’s log, tagged admin.mutation:

{"msg":"admin.mutation","actor":"account:user-1","action":"delete-workspace","target":"ws-1","result":"deleted","path":"/api/admin/workspaces/ws-1"}

actor is account:<id>, never a secret and never a role, because “somebody with the token did it” is not an audit trail. result is ok-family (created, deleted, revoked, updated, swept), refused, refused-scope or failed. The relay writes no other audit store, so ship these lines somewhere that keeps them: a log line that scrolls away is not an audit trail.

There is no owner key to lose. Recovering access is the account’s job in the control plane, and this build offers two doors: a magic link and a passkey. Both need nothing the reader has to remember, which is why the sign-in screen leads with them; a password is one press away behind a disclosure for a reader who wants one. Google is not offered — an OAuth client is one more thing an operator has to provision before the first sign-in, and a deployment that has one can add it back without changing anything else. Creating an account works the same way: an address, a link, and the link is the proof.

An account with no organization can sign in and read its own settings, and it cannot sync a workspace, because a session with no org has no tenant to be licensed under.

Forget this browser clears the cached document, the recent-workspace entry and any local editor state on that browser only. It does not end the session; sign out in the app for that.

An exported .suite file opens without an account. An exported .suitekey file from an earlier release is not read any more, and no support path restores it.

Operator-side, admin delete removes a workspace’s documents, projected share rooms, share rows, draft baselines and recorded organization. admin reset-owner no longer exists: ownership is a row in the control plane, so the operator action is the control plane’s own delete or transfer, not a file edit on the relay.

TLS is required. Serve the app, the relay and the control plane over HTTPS and WSS, do not expose the plaintext HTTP ports, and prefer one same-origin deployment. SUITE_ALLOWED_ORIGINS is the browser allowlist: unset answers a loopback origin only, a list answers exactly those origins, and * answers any origin, which is a decision an operator makes rather than a default.

Encrypt the host disk and the backups. SQLite has no encryption at rest in Suite, so use an encrypted volume for the data directory and encrypt off-site backups.

Back up all six stores together. documents, shares, workspaces, settings, licences and attachments live in separate files under /data in compose.yaml. Copy them while the server is stopped, or use deploy/backup.sh, and include any -wal and -shm sidecars. A mixed backup can pair documents with the wrong share rows, and one that omits attachments leaves every image as a permanent placeholder, because the document stores only an id. Restore into a fresh volume with deploy/restore.sh, and do not put these files on a network filesystem.

Run a single instance, because rooms, presence, projection state, the key-set cache and rate-limit counters live in one Node process and SQLite is local to it. There is no shared lock and no multi-replica mode; restarting drops live sockets and not documents.

Expect rate limits and add proxy limits. The server applies in-process fixed-window limits per client address to workspace deletion, share creation, token resolution and publish, and answers 429 with Retry-After. Override with SUITE_ROTATE_RATE_LIMIT, SUITE_SHARE_CREATE_RATE_LIMIT, SUITE_SHARE_RESOLVE_RATE_LIMIT or SUITE_PUBLISH_RATE_LIMIT, and set SUITE_TRUST_PROXY=1 behind a trusted proxy. These limits stop casual abuse, not a determined flood, so put request-size and connection limits in the reverse proxy too.

There is no user-facing delete everywhere action. The destructive operation is a SQLite command run on the host, and without --force it prints the stop-the-server instruction and exits non-zero without opening a database.

Terminal window
# Stop the collab server first. Deletes the workspace document, share rows,
# baselines, projected share documents and the recorded organization.
pnpm --filter @suite/collab-server admin delete <workspaceId> --force

The command reads SUITE_DB, SUITE_SHARES_DB and SUITE_WORKSPACES_DB, which default to suite.documents.sqlite, suite.shares.sqlite and suite.workspaces.sqlite relative to the package directory when run through pnpm --filter. Deletion is immediate and cannot be undone, so take a backup first.

The rest of this page describes what the system does. This section says what it is defending against, so a mitigation can be read as an answer to a named adversary rather than as a feature list.

Asset Where it lives now Blast radius if it is read
Document content plaintext Loro snapshots in documents.sqlite, .suite exports, backups Everything the workspace ever held. There is no encryption to break, only access to control.
Attachment bytes attachments.sqlite on the relay The same, for images: pasted screenshots, scans, photos.
Account identity Postgres: users, sessions, organizations, members, invitations Who works with whom, and every address an account signed up with.
Share tokens SHA-256 hashes in shares.sqlite A working link, which is a working credential, until it is revoked or expires.
An operator session a signed token for an address on SUITE_ADMIN_EMAILS, held in a signed-in browser Every workspace’s metadata, every link, and the ability to delete. Nothing in the environment grants it.
Licence private key never on the relay; the control plane signs with it The ability to mint entitlements for any tier.
Licence claims licences.sqlite per workspace, licence rows in Postgres Quota bookkeeping, not a credential.
Adversary Path What stops it today
Anonymous internet probing /api/*, /ws, /metrics, /api/admin/* Share resolution is the only unauthenticated read and it needs a token; /metrics and /api/admin/* answer 404 while no operator token is set; CORS names its origins; every route has a per-address rate limit.
A share-link recipient the link itself The link is written as a hash, expires (30 days by default), and can be revoked; revocation closes the sockets holding it and drops the projected draft. It cannot be turned into an account credential or used to mint another link.
A revoked member a session JWT already held The 5-minute TTL is the revocation window. A live socket is not re-authenticated mid-session, so a removed member keeps a working socket for at most that long.
Another organization naming someone else’s workspace id in a verified session First claim wins: the relay records workspace_orgs on the first verified join and refuses a later one from a different org with 403. The relay never consults Postgres per message.
A hostile member of the same organization a valid session for the same tenant Nothing above the organization boundary. Members of one tenant are trusted with each other’s workspaces by design; the role claim only labels the seat and sets capability flags in the app, and the relay does not enforce a per-role split inside a tenant.
An XSS payload in a shared document the page’s own origin The CSP in deploy/Caddyfile (script-src 'self', no unsafe-inline on the app hosts) plus React’s escaping. A successful one would read whatever the tab can reach: the in-memory session token and, on the operator panel, the token in sessionStorage.
A network attacker the wire TLS is required; Strict-Transport-Security is set on every site; the app never mixes an https page with a ws:// socket.
Someone holding a backup the bucket or /srv/backups Nothing in Suite: snapshots are plaintext. Encryption at rest is the host disk and the bucket’s server-side encryption.
A compromised dependency any package in the tree Nothing specific to Suite beyond a frozen lockfile, a pinned pnpm version and a supply-chain policy check on install. Treat better-auth, loro-* and the Stripe SDK as trusted code.
The operator docker exec, the database files, /metrics Nothing. An operator can read every document and delete any of them. This is a stated capability, not a vulnerability; the only control is the audit line every mutating admin request writes.

There is no end-to-end encryption, so the relay is inside the trust boundary for content. There is no at-rest encryption of documents, so disk access equals content access. A share link’s reader cannot be attributed beyond “the link was opened”, and presence is discarded rather than recorded. Nothing here is a promise of forward secrecy or of surviving a compromised relay host.

Content Security Policy and violation reporting

Section titled “Content Security Policy and violation reporting”

Every site sends an enforcing Content-Security-Policy. Content-Security-Policy-Report-Only appears nowhere, because a report-only policy protects nobody and a site that downgraded to one without saying so would look safe and be open.

The two sites that proxy /api/* to the relay also send report-uri /api/report/csp and report-to suite-csp, plus the Report-To and Reporting-Endpoints headers that name the same path. Both directive names are present on purpose: report-to is the Reporting API, report-uri is what the other browsers still implement.

apps/collab-server/src/http/report.ts is the collector:

  • It accepts application/csp-report, application/reports+json and a plain JSON body, and answers 204 with no body whatever happens, because a malformed report is not an error a browser can act on.
  • The body is capped at 16 KiB and reports are limited to 120 per address per minute. Past either bound the request is dropped, still with 204, so a page that reports in a loop cannot turn into a disk filler or a retry storm.
  • One csp.violation log line per accepted report carries documentUri, blockedUri, the directive, disposition, sourceFile and the line number. documentUri and sourceFile are redacted with the same rule as the request log, because the page that triggered a violation can be a share link whose URL carries a token in the query.
  • Nothing is stored and nothing is re-served. The collector is a log writer, not a database.

Two things are true at once, and the difference matters to anyone filing an erasure request.

A workspace export: the app writes a .suite file from the live document, and an operator can export the same workspace through GET /api/admin/workspaces/:id/export. The file opens without an account and contains the document; it does not contain account rows or the relay’s share records.

A workspace delete, by the account that owns it (DELETE /api/workspaces/:id), by the control plane while it erases an account (the same route with SUITE_RELAY_PURGE_TOKEN), or by an operator (admin delete). It removes, in one pass: the workspace document and every projected share room, the share rows and their draft baselines, the recorded organization row, the licence record when the relay is hosted, and every attachment row and byte of that workspace. The response reports each count, so a delete that removed less than expected is visible instead of assumed.

The local half: purgeWorkspaceLocally clears this browser’s cached document, recents entry, cached credential and licence token. Another device keeps its copy until it opens the workspace again and finds the relay empty.

An account can take its data and close itself. Both actions are control-plane routes in services/auth/src/http/routes.ts and need the session cookie only.

GET /api/account/export answers one download, suite-account-<userId>-<date>.json, with content-type: application/json and content-disposition: attachment. The shape is versioned with format (suite-account) and version (1):

Field Holds
account the user row: id, address, name, address verification, second-factor state, and the created and admitted times
organizations[] every organization the account belongs to, its name and slug, and the account’s own membership (role and joined time)
organizations[].workspaces[] the workspace id, name and timestamps, a document reference, and the workspace’s shares
shares[] share id, scope, role, created, expiry and revocation times
notes what the file does not contain, in the file itself

Document bytes are referenced, never copied. document.exportedHere is false and document.exportPath names the operator export route, because a .suite file already exports one workspace and this service holds no document store. notes.documents says the same thing to a reader who never saw this page.

The file carries nothing that belongs to another person. A member row contributes the account’s own role, never somebody else’s id, role or address, and a share contributes no token and no hash: a hash is a working credential, and this file leaves the deployment. notes.otherPeople states that.

Share metadata comes from the relay when the deployment injects a WorkspaceDataStore into the account routes. Without one, each workspace carries sharesUnavailableReason instead of an empty list, so “no links” and “could not read the links” do not look the same.

DELETE /api/account requires the account’s own id in the body as {"confirm":"<userId>"}, the shape the relay’s workspace delete already uses, so a stale tab cannot erase an account it never named.

The erase runs in this order, and the order is the design:

  1. Read the account once. An organization the account is the only member of is deleted whole. An organization another person still holds a seat in is kept, and only the account’s own member row is removed from it.
  2. Delete the relay-owned data for the workspaces of the deleted organizations: the document and its projection rooms, the share rows and their baselines, the attachment rows and their bytes. Every one of those deletes is idempotent.
  3. Delete every control-plane row in one Postgres transaction, with the user row last.

A failure before step 3 leaves the account whole and able to sign in, so the same request can be sent again and the idempotent relay deletes finish the job. A failure inside step 3 rolls the transaction back, so the account is equally intact. The user row goes last because it is the row that authenticates the retry. The account is never usable and half-deleted: its rows leave in one transaction, and the part that cannot join that transaction runs first and is reported.

The answer is a per-store report, never a bare success. 200 means the account row is gone and 500 means it is not; in both cases deleted, complete, organizations, workspaces, stores and retry describe what happened. Each entry in stores names one store (organizations, members, workspaces, invitations, invites, licences, subscriptions, sessions, accounts, verifications, passkeys, twoFactor, users, documents, shares, attachments) with a status (removed, not-removed, not-attempted or not-configured), the number it removed, and a reason and follow-up command when it removed less.

The three relay stores report removed with the count the relay answered when the deployment sets SUITE_RELAY_URL and SUITE_RELAY_PURGE_TOKEN. The control plane then sends the same delete the account sends, on its own credential, and the report carries what came back. Without that pair they report not-configured: the account is still deleted, and the report names the route that finishes each workspace, which is DELETE /api/workspaces/<id> with the owning account’s session, or pnpm --filter @suite/collab-server admin delete <id> --force.

The purge credential is its own token rather than the operator’s authority. The account service is the process with a public sign-in surface, and an operator session reaching it would let a fault there reach every workspace’s limits, rooms and links. This one deletes the workspace it is handed and opens nothing else, and it is set on each side independently, so rotating it costs one restart of each process.

Backups age out; they do not vanish. A delete is immediate on the live stores and permanent for the relay, but the snapshot that contains the deleted rows is kept until its retention window passes: deploy/backup.sh writes a daily copy and a weekly copy on Sundays, and the bucket lifecycle rule expires daily/ after 7 days and weekly/ after 35 days. The local /srv/backups directory is pruned by the find … -mtime +14 cron entry in deploy.md. So a deleted workspace or account can remain recoverable from backup for up to 35 days, and from a Postgres dump on the same schedule for the identity rows. That is the honest answer to “when is it really gone”: after the window, not on the click.

POST /api/feedback is the one route in the control plane that spends money for an unauthenticated caller: it sends an email, and anyone can reach it. It is capped rather than closed, because the most useful message comes from somebody stuck before they have an account. Three limits hold it:

  • a fixed window per address (five messages an hour), keyed on the address the proxy reports, so one caller cannot turn the box into a relay;
  • an 8 KB body and a 4000-character message, refused before the transport is touched;
  • no storage. The message, the surface and the build id go out through the same transport the account mail uses, and nothing is written down. No workspace id, no document content and no IP address travel with it.

SUITE_FEEDBACK_TO unset means the route answers 503 and the client hides the bubble: a form that cannot send is worse than no form. A transport failure answers 502 rather than reporting success, because a false thank-you for a message that never left is the one outcome worth being noisy about.