A desktop app for managing one product catalog and keeping it in sync across multiple selling platforms.
Nisaba holds a local master catalog of products and variants, syncs inventory, pricing, descriptions and photos to each connected marketplace, imports supplier catalogs through sandboxed vendor plugins, and can sync between installs over Tor.
Built with Rust + Tauri 2 and a Nuxt 3 frontend.
Captured from the real app running a fixture install (sample catalog data — no
customer data anywhere). The capture is part of the CI UI smoke test
(scripts/ui-smoke.py --screenshot ... --seed-sample-catalog).
| Dashboard | Products | Listings |
|---|---|---|
![]() |
![]() |
![]() |
| Vendors | Inventory |
|---|---|
![]() |
![]() |
- Multi-platform sync — eBay, Squarespace, XMR Bazaar and Amazon behind one
PlatformAdaptertrait, each declaring its ownPlatformCapabilitiesso the UI and sync engine only attempt what a platform actually supports. - Inventory reconciliation — a sync engine with quantity deltas, conflict resolution, and sale detection for platforms that use stock modes (unlimited / one-time) instead of numeric quantities. It also has a dry-run mode that resolves a full cycle and reports every change it would make without writing to any platform — or to the local database.
- Listing management — descriptions, photos, and multi-tier pricing, edited locally and published per platform.
- Vendor plugins — TypeScript modules run in an embedded Deno runtime to import supplier catalogs (SKUs, variants, dealer pricing, inventory). Ships with a Rothco Wholesale plugin.
- Multi-company + P2P sync — optional peer-to-peer sync between installs over Tor onion services, with AES-GCM encrypted payloads and secrets held in the OS keyring.
- Analytics — per-product history and time-windowed analytics. History is shown
as entry counts in the UI today (
inventory.vuerendershistory.length); charting the entries is still on the roadmap (Phase 1).
Each cycle polls every enabled platform, resolves a canonical quantity per product (per variant, where variants are mapped) from the summed deltas against the last-known snapshots, and pushes the resolved quantity back to any platform whose reading disagrees.
Partial-failure policy — never push to an unpolled platform. A platform with no successful reading this cycle receives no pushes this cycle. For a numeric platform that means a poll that returned the listing's quantity; for a stock-mode platform (XMR Bazaar) it means a successful poll and a successful sale-detection pass, since sale detection is that platform's reading. One platform being down does not abort the cycle — the others still resolve and sync, and the failure is reported as a platform error — but the unread platform is never written to: pushing a canonical value resolved without any reading from it could overwrite live stock on a platform that was merely slow to answer. It is reconciled on the next cycle in which it can be read again.
crates/
core/ Shared types, config, DB (turso), sync engine, conflict
resolution, analytics, crypto
platform-ebay/ eBay adapter (OAuth)
platform-squarespace/ Squarespace adapter (API key)
platform-xmrbazaar/ XMR Bazaar adapter (HTML form scraping + cookies/CSRF)
platform-amazon/ Amazon SP-API adapter (LWA OAuth)
p2p/ Tor onion service, peer client/server, company sync
vendor-runtime/ Deno-based sandbox that executes vendor plugins
tauri-app/ Tauri backend — ~96 commands exposed to the frontend
frontend/ Nuxt 3 SPA (Tailwind + shadcn-vue)
plugins/
rothco-wholesale/ First-party vendor plugin (GraphQL v2 API)
template/ Starter plugin and contract reference for third parties
migrations/ SQL migrations, applied in order
config.example.toml Template for the runtime config
The Tauri layer is the only place the frontend talks to; commands are grouped by domain
in crates/tauri-app/src/commands (products,
listings, sync, vendors, pricing, photos, analytics, company, auth,
config, logs, export_import, images).
- Rust — the toolchain is pinned to stable by
rust-toolchain.toml, matching CI, so rustup selects it for you. Plus the Tauri 2 prerequisites for your OS. - Node.js
^20.19.0 || >=22.12.0and npm — required by Nuxt 3.21 / Vite 7 and enforced by theenginesfield infrontend/package.json cargo-tauriCLI:cargo install tauri-cli --version "^2"
cd frontend && npm installcargo tauri devcargo tauri dev starts the Nuxt dev server itself (port 3456, chosen to avoid
conflicts) and then launches the desktop shell against it.
Debug builds run in sandbox mode:
- One-click sign-in goes through the sandbox broker (
nisaba-auth-sandbox.basicautomation.io, which holds eBay's sandbox keyset and Amazon's sandbox app). - eBay and Amazon calls go to their sandbox hosts.
- Config, catalog and tokens live in a separate
nisaba-sandboxdata folder. - A banner says so on every page.
Release builds never do. NISABA_SANDBOX=0 runs a debug build against the real marketplaces
and data folder, and NISABA_SANDBOX=1 turns sandbox mode on in any build. Amazon's SP-API sandbox only
returns Amazon's canned test responses, so it checks sign-in and request shapes, not real
listings.
If your global ~/.cargo/config.toml sets nightly-only flags in build.rustflags
(-Z threads=8 is a common one), every cargo command here fails with "the option Z
is only accepted on the nightly compiler" — the stable pin and those flags cannot
coexist. Cargo merges rustflags arrays across config files rather than overriding them,
so a repo-local config cannot clear it; the environment variable can:
RUSTFLAGS="" cargo build --workspaceThe UI is designed around four licensed Adobe Fonts (Clother, Clarendon Wide Sketch,
Pulpo Rust, Jubilat). Those .woff2 files are not redistributed with this repository
and are gitignored. The app falls back to system font stacks and renders correctly
without them — just not on-brand.
If you have a license, drop your own .woff2 files into frontend/public/fonts/ using
the filenames listed in frontend/assets/css/fonts.css
and they are picked up on the next build.
cargo tauri buildThis runs npm run generate in frontend/ and bundles the static output into the app.
scripts/ui-smoke.py drives the real desktop app over WebDriver
and checks what the plugin marketplace renders:
cargo install tauri-driver
cargo build -p nisaba-tauri --bin nisaba-tauri --example ui_fixture
scripts/ui-smoke.py --target-dir "$(cargo metadata --format-version 1 | jq -r .target_directory)"It seeds a throwaway install in a temp directory (every platform disabled) and runs the
app from there inside a network namespace with only loopback up, so it never sees your
real config or data and cannot reach any network. It needs WebKitWebDriver (from
webkitgtk), a desktop session, and unprivileged user namespaces.
Config lives outside the repo, at the platform data directory:
- Windows:
%APPDATA%\nisaba\config.toml - Unix:
~/.config/nisaba/config.toml
A config.toml in the process's working directory takes precedence over both of
the above — useful for development and tests. See resolve_and_load in
crates/core/src/config.rs: an explicit CLI path wins
first, then ./config.toml in the working directory, then the data-directory file.
A default file is written on first run if none exists. Copy
config.example.toml as a starting point — it covers the sync
schedule, database path, per-platform credentials, low-stock alert thresholds, and the
P2P company settings.
Products are not configured in TOML; they live in the database and are managed
through the UI. config.toml and *.db files are gitignored.
The database is SQLite-compatible (via turso), defaulting to nisaba.db in the data
directory. Schema changes go in migrations/ as a new numbered file.
A vendor plugin is a TypeScript module executed in a locked-down Deno runtime — no
ambient network or filesystem access, only what Nisaba injects as a Nisaba global:
Nisaba.fetch, Nisaba.sleep, Nisaba.log.{trace,debug,info,warn,error}, and
Nisaba.emitBatch for streaming listings back to the host as they are scraped.
console.log and friends write to the same log as Nisaba.log.
A plugin exports two things:
export const metadata = {
name: 'Rothco Wholesale',
version: '2.5.0',
description: '…',
category: 'vendor',
config_fields: [
{ key: 'api_token', label: 'API Token (Bearer)', required: true, secret: true },
],
allowed_hosts: ['www.rothco.com'],
}
export async function fetchListings(
config: Record<string, string>
): Promise<any[]> { /* … */ }Metadata is read without running fetchListings, so the app can show a plugin's config
fields before it is ever executed. secret: true masks a field's input; the values
themselves are currently stored unencrypted in the app's database and shared with the
company's P2P peers (moving them to the OS keyring is on the roadmap). Plugins may be
single-file or multi-file.
Writing a plugin: start from plugins/template. It is a
working plugin whose comments are the contract reference — the Nisaba API, every
metadata and config_fields option, the listing schema (including variants and the
extras keys the app recognises), paging, batching, rate-limit retries, and when to
throw versus log and skip. plugins/rothco-wholesale is a
real-world example that pages a GraphQL catalog.
What the sandbox enforces:
- Filesystem — none. A plugin can import only its own files; imports that resolve outside its directory are refused, and a plugin's file names must be plain relative paths, so installing one cannot write outside the directory it is unpacked into.
- Host APIs — only
Nisaba. TheDenoglobal is removed before plugin code runs. - Network —
Nisaba.fetchis the only way out. A plugin lists the hosts it needs in its metadata asallowed_hosts: ['api.example.com', '*.cdn.example.com'], and fetches (and redirects) anywhere else are refused;[]means no network at all. A plugin that declares no list can still reach any host — declare one. Code at a module's top level runs when the plugin is installed and has no network access. - Resources — each
fetchListingsrun gets 30 minutes of wall-clock time, a 1 GiB heap, 256 MiB of listing output (everyemitBatchplus the return value) and 64 MiB perNisaba.fetchresponse body; a metadata read gets 10 seconds, 128 MiB, and 1 MiB for each of the other two. A plugin that exceeds one is stopped with an error naming the limit; it cannot hang or crash the app. The time limit can be raised per plugin (up to 12 hours) on its card under Marketplace → Installed.
Experimental / Preview. Company sync is experimental and not part of the 1.0 promise:
1.0 ships as a single-machine product, and company sync sits behind a "Preview" switch
that is off by default. Today's Tor-based sync does not work end to end; Sync v2 (see
ROADMAP.md, Phase 5) will replace the transport and data layer after 1.0.
Each company an install belongs to publishes a Tor onion service and syncs catalog state
with its peers on an interval. ([company].enabled in config.toml is currently not
read — the onion service starts for every company regardless; see the roadmap.)
The sync payload is JSON sent over the onion connection, so it is protected by Tor's
end-to-end encryption; peers authenticate with the shared company secret, which is kept
in the OS keyring. There is no additional application-layer encryption of the payload,
and it includes platform auth tokens and plugin configuration. The company's platform
configuration is stored AES-GCM-encrypted (key derived from the secret with HKDF). See
crates/p2p.
Merge policy. Each section of the payload merges independently: products, variants,
mappings, auth tokens, the company config and logo, and registry plugins are
last-writer-wins on updated_at; a deleted mapping is a tombstone that propagates;
platform snapshots keep the newer last_polled_at; processed XMR orders are a union.
Peers that hold the same data converge — a repeated sync changes nothing
(crates/p2p/tests/round_trip.rs).
Trust model — read this before adding a peer. The company secret is the only credential. Anyone who holds it is, in effect, a full member: they receive the whole payload (platform auth tokens and plugin configuration included), and whatever they send is merged. Beyond that, the current protocol trusts what a peer says about itself:
- A peer's identity is the
sender_peer_id/sender_onionit puts in its own request, so a secret holder can speak as any authorized peer — and doing so updates that peer's recorded onion address./api/address-updatecan repoint any peer. - Admin-only endpoints (adding, approving and removing peers, changing roles) check the receiving node's role, not the caller's, so any secret holder can call them on an admin's node.
updated_atcomes from the sender. Rows stamped more than 10 minutes ahead of the receiver's clock, or with an unparseable stamp, are refused; within that window a peer with a fast clock still wins ties.- Secret rotation is not implemented yet.
Share the secret only with installs you would give your marketplace credentials to. The fixes are on the roadmap (Phase 5).
Pre-1.0 and under active development: the sync engine has not yet been exercised against
all four platforms in production. 0.x releases are published as normal GitHub releases
(not pre-releases) because the in-app updater only sees GitHub's "latest" release. A
version with a suffix, such as v0.3.0-beta.1, is a pre-release.
ROADMAP.md is the single source of truth for planned work: a phase-ordered
checkbox queue covering test coverage, the platform capability gaps, the plugin platform,
P2P, and the release pipeline. Known gaps worth calling out up front:
- No auto-update yet — the Tauri updater ships with an empty signing key.
- Photo upload is unimplemented on every platform.
- Test coverage is uneven. The sync engine, the database migrations, XMR Bazaar's HTML scraping, all four adapters' mapping layers and the vendor plugin runtime and sandbox have tests; every adapter's live network path and the P2P layer do not.
Found a vulnerability? Please report it privately through this repository's Security
tab rather than opening an issue — SECURITY.md has the details, including
what counts (credential handling, the vendor plugin sandbox, the P2P layer, and adapters'
handling of untrusted marketplace responses) and what does not.
PRIVACY.md states what leaves the machine (update checks,
marketplace API calls, P2P relays) and what never does; the per-platform API
terms review lives in docs/roadmap/platform-terms-review.md.
MIT




