Nuvaka › Developer docs › Concepts

Concepts

Where an extension runs, how its identity is established, how permissions are asked for, and how data stays consistent across devices.

Isolation and identity

The UI frame

The UI runs in a sandboxed frame opened by the app: sandbox="allow-scripts allow-forms", with no same-origin access. As a result:

  • There is no localStorage, sessionStorage, IndexedDB or cookies. Use nuvaka.storage for persistent data.
  • allow-modals is not granted, so alert(), confirm() and prompt() do not work; ask questions inside the page.
  • The Content Security Policy response header:
    default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; media-src 'self' blob: data:; img-src 'self' data: blob:; connect-src 'none'; frame-src 'none'; form-action 'none'
    Scripts load only from the package (no inline <script>, no remote scripts), fetch/XHR/WebSocket are blocked (use nuvaka.net.fetch), inline styles are allowed and WebAssembly works.
  • Files are served from the verified package. The frame's address is an app internal and differs per platform; use relative paths only (style.css, img/logo.svg).
  • Using window.parent, top, opener or document.cookie is rejected by the package scan. The only channel to the app is nuvaka.*.

Identity chain

The app's trusted shell opens the frame and binds a dedicated MessageChannel port to it. The shell forwards every call from that port to the app's Rust layer with the extension ID it knows; any ID the extension sends is ignored. The Rust side checks from its own records that the extension is installed and enabled, and checks its permissions and version.

Every call that needs the server (storage, notes, cloud, mail…) is made by the app with an extension token specific to that extension and valid for 15 minutes. It only works on the extension endpoints (/api/ext/v1/x/*) and only carries the server permissions the user granted. The user's session token is never given to the extension and never used for requests on its behalf. If the user's session is revoked, the extension token dies with it.

Account, password, 2FA, session, payment and admin endpoints are never exposed, whatever the permissions.

Background

  • QuickJS engine; each extension runs in its own thread and runtime. Memory limit 32 MB.
  • The continuous execution slice is 5 seconds: infinite loops are interrupted. Split long work with await.
  • No eval and no Function. The code is a single-file ES module and cannot import; bundle multiple files into one.
  • No DOM and no fetch. setTimeout/setInterval, console.* (writes to the app log), btoa/atob and TextEncoder/TextDecoder are available.
  • The background runs only while the app is open; nothing runs while it is closed.

Theme

The shell passes the theme as CSS variables; the SDK applies them to the root element and sets the data-nv-theme="dark|light" attribute and color-scheme. Variables: --nv-bg, --nv-surface, --nv-elevated, --nv-border, --nv-text, --nv-text-2, --nv-muted, --nv-accent, --nv-accent-contrast, --nv-success, --nv-warning, --nv-danger, --nv-radius, --nv-font. Listen for changes with nuvaka.ui.onTheme(fn).

body { background: var(--nv-bg, #0f1115); color: var(--nv-text, #e6e6e6); font-family: var(--nv-font, system-ui, sans-serif); }
button { border: 1px solid var(--nv-border, #333); border-radius: var(--nv-radius, 8px); }

Permissions and risk classes

An extension lists every permission it wants in its manifest, each with a reason (reason). The install screen colours permissions by risk class:

ClassColourExamples
lowgreennotifications, background, startup, nuvaka.push
mediumyellownet, clipboard: write, nuvaka.friends, nuvaka.connections: list
highorangefiles, clipboard: read, nuvaka.notes, nuvaka.cloud: read, nuvaka.clipboard: read
criticalrednuvaka.mail, nuvaka.cloud: write, nuvaka.connections: secrets

To install an extension that asks for critical permissions the user gives a second confirmation ("I understand: … asks for critical permissions"). If a reading permission is requested together with net, the app adds a warning that the data can be sent to those addresses (dangerous pairs).

Users can revoke permissions later, so your code should expect a permission_required error on any call. If an update widens the permission set or the net hosts, automatic updating stops and the old version keeps running until the user approves.

Full list: Permissions reference.

Critical confirmation

These scopes are never permanent, even when granted at install; the user is asked on every use:

  • nuvaka.mail.read — reading mail folders, lists or a message
  • nuvaka.mail.send — sending mail as the user (recipient and subject are shown)
  • nuvaka.connections.secrets — reading the password/key of a saved connection
  • nuvaka.cloud.write — uploading to or deleting Nuvaka files

The app opens a dialog describing the operation. The user can pick Only this time, 15 minutes or 1 hour; there is no permanent option. A timed approval lets that scope through without asking until it expires, and the user can revoke it at any time. If the user denies, or does not decide within at most 2 minutes, the call fails with code permission_denied (ConfirmationDeniedError in the SDK). An extension can have at most 3 pending confirmations; more are rejected with confirmation_flood.

try {
  await nuvaka.mail.send(accountId, { to: '[email protected]', subject: 'Report', body: 'Attached.' })
} catch (e) {
  if (e.code === 'permission_denied') showInline('Sending was not approved.')
  else if (e.code === 'permission_required') showInline('Mail sending permission not granted.')
  else throw e
}

Also: every mail sent and every connection password read lands in the user's notification feed; sending is limited to 30 mails per hour per extension.

Storage, MVCC and change events

Every extension has its own key-value storage on the server; no permission is needed. Keys are 1–200 characters and may contain / (summary/42); values are JSON up to 1 MB.

Every write carries the version it read

Storage uses MVCC: set and delete require the version you read (0 for a new key). If it does not match the server, i.e. another device or the background wrote after you read, the call fails with version_conflict and the error carries the current version and value. Without a version the SDK rejects the call with if_version_required before sending it.

const cur = await nuvaka.storage.get('settings')              // { value, version } | null
await nuvaka.storage.set('settings', { dark: true }, { version: cur ? cur.version : 0 })

Most of the time storage.update is enough: it reads, calls your function with the old value, writes with the version read and, on conflict, retries with the current value up to 5 times. If your function returns undefined, nothing is written.

// Counter: no increment is lost even if two devices increment at once
await nuvaka.storage.update('count', (n) => (n || 0) + 1)

// Write only when needed
await nuvaka.storage.update('seen', (list = []) => list.includes(id) ? undefined : [...list, id])

Version numbers

Versions do not go 1, 2, 3 per key: they come from a single, ever-increasing sequence shared by all of the extension's data. A delete also gets a new version; a key that is deleted and recreated always gets a higher version. Treat the version as a "state I read" marker, not a counter.

Change events

Every write and delete sends an event to all of the user's devices. The SDK keeps the highest version it knows per key and delivers only newer versions; a delayed or out-of-order event can never bring back an old value. When an event arrives the SDK reads the value itself:

const off = nuvaka.storage.onChanged((key, change) => {
  if (key === null) return resetAll()           // all of the extension's data was deleted
  // change: { value, version, deleted }
  render(key, change.deleted ? null : change.value)
})

A context (frame or background) does not receive its own writes. On the same device, the background's writes reach the UI, the UI's writes reach the background, and other devices' writes reach both.

Sync

  • Installs belong to the account: an extension installed on one device is downloaded, verified and installed on the others; removal syncs too. The user can stop an extension on one device or on all devices.
  • Storage lives on the server; every device sees the same data. Permissions, settings and the notification limit are also the same across devices.
  • Local folder picks (files) are per device and are not synced.
  • The background runs separately on every device. Every device with the app open fires its own schedules, so two devices may do the same job at the same time. Claim one-off work with MVCC (see GitHub watcher).
  • A schedule missed while the app was closed fires once at the next start; for daily/weekly only the latest one.
  • Updates: per extension the user chooses automatic, ask or off. Versions that do not change permissions can install automatically; the previous version is kept on the device and can be restored.

Notification limit

By default an extension can send at most 3 notifications in the last 60 seconds. Local notifications (nuvaka.ui.notify) and notifications to all devices (nuvaka.push) share the same counter. When the limit is exceeded the app asks the user whether to raise it (to 5, 10, 20 or 30 per minute):

  • If the user raises it, the call goes through; the new limit applies on all devices.
  • If the user says "No", they are not asked again for 24 hours and calls over the limit fail with notify_limit (NotifyLimitError in the SDK). Notifications arriving while the question is open fail the same way.

The user can change the limit (1–60 per minute) in the extension's row under Apps › Manage apps. Your code should catch the error and carry on:

try {
  await nuvaka.push('New release', 'tauri v2.1.0')
} catch (e) {
  if (e.code !== 'notify_limit') throw e
  await nuvaka.storage.update('pending', (list = []) => [...list, 'tauri v2.1.0'])   // retry later
}

A local notification's title is shown as "Extension name · title"; the title is cut at 100 and the body at 500 characters. A push title is required.

Storage quota

Request a quota between 1 and 100 MB with "storage": { "quotaMB": N } in the manifest; the default is 10 MB. The quota is per user and per extension and covers all of the extension's data (key-value plus file storage). A write over the quota fails with quota_exceeded (QuotaError in the SDK).

When removing an extension the user can choose to keep its data for 30 days; reinstalling within that time brings it back.

Rate limit and audit

Server calls are limited to 300 per minute per extension (rate_limited when exceeded). A summary of every server call is recorded, so the user can see what an extension did.

Nuvaka Apps API v1 · last updated 2026-09-27