Tintero Developers

Security model & limits

The sandbox your code runs in, why every call is checked, how network access works, and every runtime limit with the constant it comes from.

On this page

People install plugins written by strangers into the app holding a novel they have spent two years on. Everything below is what makes that a sane thing to do, and it is worth reading once even if security is not what you came for: most of the rules on the other pages stop looking arbitrary afterwards.

Where your code runs

Sandbox attributes

  • allow-scripts : Your JavaScript runs.
  • allow-forms : Forms submit. Added only for surfaces that render UI.
  • No allow-same-origin, so the frame has an opaque origin. There is no host document, no cookies and no shared storage to reach.

Content security policy

  • default-src 'none'
  • script-src 'unsafe-inline'
  • style-src 'unsafe-inline'
  • img-src data:

What the host puts in your document

An empty plugin-root element to mount into, the SDK, the current theme's CSS variables, and a console bridge that forwards your logs to the host.

What that means for you:

  • You cannot touch the app. Not its interface, not its storage, not another plugin’s data. There is no window.parent to reach through.
  • You cannot reach the network from inside the frame. fetch and XMLHttpRequest fail. To get online you call tintero.net.fetch, and the app makes the request for you, only to hosts you listed up front.
  • You cannot store anything yourself. localStorage is not there. Use tintero.storage, which is your own private box, keyed to your plugin id, and survives reloads.
  • Images have to be inline. The content policy allows data: images and nothing else, which is why project.getImageData() hands you a base64 URL rather than a path.

Every call is checked

Your code never reaches a Tintero service directly. It sends a message, the app checks it, and the app makes the call. Three checks run on every call, in this order:

  1. Permission. Was this method covered by what you were granted? Which method needs which permission is decided by the app, not by your manifest, so there is no widening it from your side.
  2. Rate limit. Go past 100 calls / 1 s and the call is refused.
  3. Argument size. Anything over 5 MB is refused before it is even read.

A refusal always comes back as an error you can catch. Nothing ever fails quietly:

try {
  await tintero.project.getCharacters();
} catch (error) {
  if (error.message.includes('SCOPE_DENIED')) {
    // the manifest did not ask for project.read.characters
  }
}

Error codes

CodeMeaning
SCOPE_DENIEDThe permissions you were granted don’t cover this method.
RATE_LIMITEDToo many calls, too fast.
PAYLOAD_TOO_LARGEThe arguments you passed were over the cap.
NETWORK_DENIEDA net.fetch to a host outside network.domains, or a request that failed.

The code is in the message text, which is why checking error.message works.

Approval, and what a reload does not change

Your permissions get approved once, at install. The dialog lists exactly what your manifest asked for, grouped by how risky each one is, and the answer is saved against your plugin.

Implicit grants

Some permissions bring others with them, because the job would be impossible otherwise. An exporter that cannot read file contents has nothing to export. Asking for both just makes your install dialog longer for no benefit. The full list is on the permissions page.

Network access

Your frame cannot reach the network at all. Requests go out through the app instead, and only to hosts you named in your manifest:

{
  "id": "com.example.sync",
  "name": "Sync",
  "version": "1.0.0",
  "description": "Pushes chapters to an external service",
  "author": { "name": "You" },
  "type": "tool",
  "surfaces": ["background"],
  "main": "plugin.js",
  "scopes": ["net.fetch", "project.read.files"],
  "network": { "domains": ["api.example.com", "*.cdn.example.com"] }
}

You can write an exact host, or put a wildcard on the front to cover subdomains. A bare "*" is refused at install, and asking for net.fetch without any network.domains stops the install outright.

The rest of the rules:

  • Protocols: http and https only.
  • Methods: GET, POST, PUT, PATCH, DELETE, HEAD.
  • Response size capped, and the request times out. See the table below.

Paths

Every path you write gets added onto your own plugin folder: the files in your zip, main, icon, the ui paths, and anything you ask fs for. A ../ that slipped through would read or write outside that folder, so paths are checked before they are joined. The exact rules are on the manifest page.

Your plugin id gets checked for the same reason. It is a folder name too.

Content is validated before it is written

updateFileContent() and updateDocContent() read and check what you send before they touch the file. A bad write fails and the chapter is exactly as it was.

The order is the whole point. Checking afterwards would mean a buggy plugin wrecks somebody’s chapter and then tells you about it.

Liveness

Tintero pings your plugin every 15 s and the SDK answers for you, with nothing to write on your side. Stay quiet for more than 3 in a row, roughly 60 s, and your frame is treated as hung and shut down.

Separately, a plugin that throws more than 50 errors / 60 s gets switched off. Both exist so that one broken plugin cannot take the editor down with it.

Every limit

Limit Value When you hit it
API calls
RATE_LIMIT_MAX_CALLS
100 per 1 s, per plugin The call rejects with RATE_LIMITED.
Call arguments
MAX_PAYLOAD_BYTES
5 MB The call rejects with PAYLOAD_TOO_LARGE.
Call duration
CALL_TIMEOUT_MS
30 s The promise rejects; the host stops waiting.
Heartbeat
HEARTBEAT_INTERVAL_MS
ping every 15 s The SDK answers automatically; you do nothing.
Missed heartbeats
PLUGIN_HEARTBEAT_MAX_MISSED_PINGS
more than 3 in a row The iframe is destroyed as hung, after about 60 s of silence.
Error budget
PLUGIN_ERROR_BUDGET_MAX_ERRORS
more than 50 per 60 s The plugin is disabled.
net.fetch response
MAX_RESPONSE_BYTES
10 MB The request rejects with NETWORK_DENIED.
net.fetch timeout
NET_TIMEOUT_MS
20 s The request is aborted.
Dialog size
openDialog
600 × 400 by default, capped at 95vw / 90vh A larger request is clamped to the cap.

Nobody typed these numbers into this page. Each one is read from the constant named beside it when the site is built, so if a limit changes in Tintero, this table changes with it.

What none of this protects against

Worth saying plainly:

  • A plugin you gave write access can rewrite your project. The sandbox limits how code runs, not what an approved permission lets it do. Read what you are approving.
  • A plugin with net.fetch can send whatever you gave it anywhere on its own list of hosts. One holding read access and network access together is one you had better trust.
  • Nobody checks plugins automatically. The permission dialog is the review.

Which is why the advice to authors never changes: ask for the least you can work with, and use your description to say why you need it.