Storage, files and network

Applies to: StablEdge Desktop 0.4.4

A workspace interface has no direct access to the file system or the network. Persist data in the KV and Blob storage the host provides, import and export through system dialogs or drag and drop, and reach the network through the host's proxy.

Storage layers

DataUseDo not
A little settings, bounded indexes, blob_idstorage.kvStore Base64, full text, thumbnails or unbounded arrays
Images, documents, audio and media, large statestorage.blobTreat a session's src as a lasting address
Backups and imports you can seefiles.dialogGet or assemble a system path

Limits: 64 KiB per KV entry and 1 MiB per plugin in total; 64 MiB per blob and 512 MiB per plugin in total by default. You can raise the blob limits in Settings → Plugins (1–256 MiB per blob, 64 MiB–8 GiB in total). At runtime read blob_item_limit_bytes / blob_total_limit_bytes from what storage.getUsage() returns, and never hard-code the defaults in a plugin.

KV

const state = await stabledgePlugin.storage.get("state");
await stabledgePlugin.storage.set("state", nextState);
await stabledgePlugin.storage.delete("state");
const usage = await stabledgePlugin.storage.getUsage();

getUsage() returns kv_used_bytes, kv_total_limit_bytes, kv_item_limit_bytes, blob_used_bytes, blob_total_limit_bytes and blob_item_limit_bytes. A usage check is only an early warning; you still have to handle the quota error the host returns when the write actually happens.

For large editable objects, keep the body in a Blob and the index in KV:

  1. Write the new blob.
  2. Read it back and check it.
  3. Update the KV index to point at the new blob_id.
  4. Delete the old blob once that succeeds.
  5. If it fails, keep the old index and clean up the new orphan blob.

Blob

const ref = await stabledgePlugin.storage.blob.put(blob, {
  media_type: "application/json",
  filename: "asset.json"
});

const reopened = await stabledgePlugin.storage.blob.resolve(savedBlobId);
const all = await stabledgePlugin.storage.blob.list();
await stabledgePlugin.storage.blob.delete(ref.blob_id);

Blob reference

FieldTypeNotes
blob_idstringLasting identifier; keep only it and the metadata you need.
srcstringA same-origin temporary URL for the current session that changes when the plugin is reopened, so do not persist it.
media_typestringMIME type.
size_bytesnumberSize in bytes.
filenamestring | nullOriginal file name.
created_atnumberCreation time (Unix seconds).

Reading a blob

The Bridge has no read method such as storage.blob.get; read through src with a same-origin request, and for images and media you can set an element's src straight away:

const current = await stabledgePlugin.storage.blob.resolve(blobId);
const response = await fetch(current.src);
if (!response.ok) throw new Error(`blob read failed: ${response.status}`);
const value = await response.json();

img.src = current.src;

Accepted types: images and audio (recognized by file header), application/pdf, application/zip, application/gzip, and application/json and text/plain (which must be UTF-8, and JSON must parse). SVG and HTML are not allowed.

File drops

Declare drops in the manifest workspace and request storage.blob as well. When you drop files into the workspace (dragging from the app's file tree works too), the host checks extensions, count, size and type for the whole batch, writes them into the plugin's blob storage and then sends an event:

stabledgePlugin.events.on("host.files_dropped", async ({ files }) => {
  for (const file of files) await importBlob(file);
});

The plugin only receives blob references and never a system path. If one file in the batch is unacceptable the whole batch is refused, and no "partly succeeded" event is sent.

System file dialogs

Request files.dialog (optional is recommended) along with storage.blob:

const opened = await stabledgePlugin.files.openFile({
  extensions: ["json", "png"],
  max_bytes: 10 * 1024 * 1024,
  title: "Import a file"
});
if (!opened.cancelled) await importBlob(opened.file);

const exported = await stabledgePlugin.files.exportBlob(ref, {
  suggested_filename: "backup.json"
});
if (!exported.cancelled) showSaved(exported.bytes_written);

The extensions of openFile can only be a subset of this allowlist: png jpg jpeg gif webp avif bmp wav mp3 flac ogg m4a pdf zip gz gzip json txt. A cancelled dialog is a normal result. There is no lasting folder grant for now, and no interface for reading and writing by relative path inside an authorized folder.

Content security policy for the iframe

The host injects this CSP into a workspace iframe:

default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data: https:; font-src 'self'; connect-src 'self'; media-src 'self';
object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'

In other words: scripts, fonts and media can only come from files in the package and from a blob src; <img> additionally allows data: and any https: image; fetch can only reach its own origin (that is, a blob src), so every external request goes through net.fetch; and the page can embed no child iframe and submit no form.

Network requests

The interface side and the worker side each have their own network path, held by different grants, and the two cannot be mixed:

CallerAPIWho holds the permissionScope
Workspace / top bar pagestabledgePlugin.net.fetch"worker": "host"Any http/https address, this machine and the local network included
WorkercallHost("host.net.fetch", …)a real worker idhttps:443 only, filtered by scope, public addresses only

net.fetch on the interface side

const response = await stabledgePlugin.net.fetch("https://api.example.com/v1/items", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ q: "hello" }),
  response_type: "json",
  timeout_ms: 15_000
});
if (response.status === 200) render(response.body);
OptionNotes
methodGET, HEAD, POST, PUT, PATCH, DELETE.
headersRequest headers.
bodyRequest body as a string.
body_encodingutf8 (default), base64 or binary_base64. The last two are decoded first and then sent as bytes, for uploading images or multipart, and must stay under 32 MiB after decoding.
response_typetext (default), json, base64 or blob. blob needs an active workspace and storage.blob.
timeout_msTimeout.
max_bytesLargest response per call; the host's hard limit (32 MiB) still applies.

A response carries status, headers and body_type: for text / json / base64 the content is in body, and for blob_ref it is in blob. The host does not follow redirects and does not share browser cookies.

  • The interface side has only four checks: the plugin is enabled, the manifest declares net.fetch for host, you have granted it, and the URL is http/https. scope and connect are shown on the install page and are not a security boundary.
  • When you configure the service address yourself (a local or network service, say), do not hard-code it into connect or scope; leave them out, and changing the address needs no new package.
  • An undeclared permission, an ungranted one or a disabled plugin returns PLUGIN_HOST_SERVICE_DENIED, which is not retryable; a URL that is not http/https returns PLUGIN_HOST_NET_DENIED.

host.net.fetch on the worker side

See Worker · Network. A host grant on the interface side does not let a worker reach the network, and the other way round either.

Host services

services.invoke can call only the services and methods the host has registered, for example reading aggregated system metrics:

const sample = await stabledgePlugin.services.invoke("host.system_metrics", "sample", {});

The matching permission is { "permission": "system.metrics.read", "worker": "host" }. It is not a general command channel and cannot be used to forward worker operations.

Opening external links

Request ui.open_external and call it inside a real user click:

button.addEventListener("click", () =>
  stabledgePlugin.ui.openExternal("https://example.com/help")
);

It accepts absolute http(s) URLs only. Do not use window.open, a top-level navigation, javascript:, file: or a URL carrying a username and password.

Credentials and logs

  • Never put long-lived credentials in the plugin package, KV, a snapshot, a URL or front-end source; when you need an API key or OAuth, use the manifest's credentials and let the host keep and inject them.
  • Do not write API keys, cookies, Authorization headers, full query strings or response bodies into logs.
  • When a local service is unreachable, show an offline or reconnecting state instead of leaving the workspace blank.
On this page