MutkaMutka
Modules

Storage, Network & Secrets

Persisting config, making host-proxied HTTP calls, and storing credentials in the Keychain — and why each is a separate capability.

Beyond the file system, a module often needs to remember settings, talk to a server, or keep a password. Each is its own capability with its own permission, so a module asks for exactly what it needs and nothing more.

Config — per-module persisted settings

storage permission. Small key/value persistence, namespaced per module so two modules can't read or clobber each other's settings (the key is mutka.modcfg.<moduleId>.<key> under the hood).

permissions: ["storage"],
setup(host) {
  await host.config.set("accounts", JSON.stringify(list));
  const raw = await host.config.get("accounts");   // string | null
}

Values are strings — serialize structured data yourself (JSON). This is for settings, not secrets: it's plain localStorage, readable by anyone with the file. Passwords go in the Keychain (below).

Secrets — credentials in the macOS Keychain

secrets permission. Per-module credential storage backed by the macOS Keychain — never plaintext on disk. Each module gets its own Keychain service (mutka.<moduleId>), so modules are isolated from each other's secrets.

permissions: ["secrets"],
setup(host) {
  await host.secrets.set(accountId, password);
  const password = (await host.secrets.get(accountId)) ?? "";
  await host.secrets.delete(accountId);
}

Why split config and secrets

They have different threat models. Config is convenient but inspectable; secrets are protected by the OS. Splitting them means a module that only needs to remember a window size (storage) never gets to ask for Keychain access, and a credential never accidentally lands in localStorage. WebDAV uses both: account metadata in config, each password in secrets.

Network — host-proxied HTTP

network:public or network:local permission. Outbound HTTP that runs in Rust, not the WebView, which means it bypasses CORS — essential for talking to arbitrary servers (WebDAV, S3, an API) that a browser fetch would block. There is no blanket network: declare network:public for HTTPS to public domains, or network:local for a self-hosted server / NAS on a private IP or localhost (both if you need both). The URL is tier-checked in Rust on every call.

MethodDoes
host.net.request(opts)A general request (url, method, headers, body)
host.net.download(opts)Saves a URL to a temp file, returns its path
host.net.upload(opts)PUTs a local file's bytes to a URL
permissions: ["network:public"],
setup(host) {
  const res = await host.net.request({
    url: "https://dav.example.com/remote.php/dav/",
    method: "PROPFIND",
    headers: { Authorization: `Basic ${btoa(`${user}:${pass}`)}` },
  });
}

download and upload stream through the file system rather than the JS heap, so they handle large files without loading them into memory.

No `database` capability — by design

There is deliberately no SQL or database capability in the core. A .sqlite file is the database: a module reads its raw bytes with fs:read (host.fs.readBytes → a Uint8Array) and decodes the format itself, entirely inside its sandboxed worker — no Rust, no server, no extra permission. See com.sqlite-browser, which parses the SQLite on-disk format in the worker and renders its tables and rows with declarative UI. This is the pattern any format viewer (zip, pdf, parquet, …) should follow: read bytes, parse in the worker. Keeping it out of the core means the attack surface stays small and the byte-reading already covered by fs:read does the job.

Putting it together

These four capabilities are why a single ~300-line WebDAV module can offer multi-account remote storage: network:public to reach the server, secrets for each password, config for the account list, ui for its settings section, and fs:read to read bytes — each requested explicitly, each independently denied if the module asked for more than it should. See the capability reference for the full list.

On this page