Cross-browser compatible authentication and identity management for Swarm dApps.
Documentation | Identity UI | Demo
- lib/ —
@snaha/swarm-idTypeScript library for authentication and Bee API operations - ui/ —
@swarm-id/uiSvelteKit identity UI (trusted domain) - demo/ — Demo dApp with library integration examples
- docs-site/ — Starlight (Astro) documentation website
- signaling/ —
@swarm-id/signalingaccount-bus signaling and relay server
The project uses an OAuth-style popup authentication flow over shared localStorage — no Storage Access API and no browser extension. The proxy iframe reads the trusted domain's first-party store while the embedding page is same-site, which covers the local rig and both deployments; where the two are cross-site, or the browser partitions regardless (Safari's ITP, strict privacy settings elsewhere), the connect popup hands the iframe the account's upload credentials directly instead, so uploads keep working (Account bus). That path is confirmed on real Safari, upload included (iOS 18.7 / Safari 26.6), see Safari limitations.
Key Innovation: The popup-based authentication allows dApps to securely derive app-specific secrets from a master identity, with browser-enforced storage partitioning providing cross-app isolation.
pnpm add @snaha/swarm-idimport { SwarmIdClient } from '@snaha/swarm-id'
const client = new SwarmIdClient({
iframeOrigin: 'https://swarm-id.snaha.net',
metadata: {
name: 'My dApp',
description: 'A demo Swarm application',
},
onConnectionChange: (info) => {
console.log('Connection changed:', info.identity?.name, 'canUpload=', info.canUpload)
},
})
await client.initialize()
// A connected user can still have canUpload=false (no postage stamp and no
// subsidised gateway), so gate uploads on both.
const info = client.connectionInfo
if (info.identity && info.canUpload) {
const result = await client.uploadData(new TextEncoder().encode('Hello, Swarm!'))
console.log('Uploaded:', result.reference)
}
client.destroy()The latest main build of every app deploys to root paths, and every PR gets previews under
…/pr-N/ (workflows: deploy-main-pages.yml, deploy-preview.yml):
| Path | App |
|---|---|
swarm.snaha.net/id/ |
identity UI (ui/) |
swarm.snaha.net/demo/ |
demo, running against /id |
swarm.snaha.net/docs/ |
documentation site |
Deployed on every push to main (workflow: deploy-do.yml):
swarm-id.snaha.net (ui/build/)
- New SvelteKit identity UI (
ui/) - Proxy/connect pages for iframe communication
swarm-demo.snaha.net (demo/build/)
- SvelteKit demo app showcasing SwarmIdClient integration, run against swarm-id.snaha.net
- Built with
@sveltejs/adapter-static
swarm-id.snaha.net/bus (signaling/)
- The account-bus signaling and relay server, as the
bus-signalingservice of the same app; the UI build points at it throughPUBLIC_BUS_SIGNALING_URL
pnpm install
pnpm devOpen http://localhost:3500 - that's it!
- Demo app runs on port 3500
- Identity UI runs on port 5500
- No HTTPS, certificates, or custom domains required (
localhostis a secure context)
Note: On Safari the proxy iframe's storage is partitioned, so the connect popup hands the session over directly. A handover carrying no account view is refused; a session whose account has no usable postage batch connects but cannot upload, and ConnectionInfo.uploadUnavailableReason says why. See Safari limitations.
# Start the full stack (identity UI :5500 + demo :3500 against it)
pnpm dev
# Or start individually
pnpm dev:ui # Identity UI on port 5500
pnpm dev:demo # Demo on port 3500, connected to the identity UI (:5500)
pnpm dev:lib # Library watch mode (rebuilds on changes)For local development with postage stamps and uploads, use @snaha/bee-compose to run a local Bee cluster with blockchain. Requires Docker.
# Start cluster (queen + 3 full workers)
pnpm dev:cluster:start
# View logs
pnpm dev:cluster:logs
# Stop cluster
pnpm dev:cluster stop
# Fresh start (purge data; add --pull after a bee-compose bump)
pnpm dev:cluster:start --freshEndpoints:
| Service | URL |
|---|---|
| Queen Bee API | http://localhost:1633 |
| Worker 1 API | http://localhost:16331 |
| Blockchain RPC | http://localhost:9545 |
Getting a Postage Batch:
The easiest way is to use the Developer Tools page in the Identity UI:
- Navigate to http://localhost:5500/dev
- Go to the Chain tab and select an account
- Click Create drive to test with — it buys a real batch on the local chain, owned by that account's own postage signer, and attaches it as a drive
Or use the Bee API directly:
# Buy stamp (amount=500000000, depth=20)
# The amount must exceed ~414720000 — the local chain's price (24000)
# times Bee's 17280-block (~24h) minimum validity.
curl -X POST "http://localhost:1633/stamps/500000000/20"
# Wait ~30 seconds, then verify it's usable:
curl "http://localhost:1633/stamps/<batchID>"See the Local Development guide for client-side stamp signing, known dev keys, SSH tunnel setup, and more.
Buying, extending and resizing a drive all cost money, and the payment is cross-chain: the user pays on whatever chain they hold funds on, and xDAI arrives on Gnosis. That leg runs on Relay Protocol, an intent/solver network — its quotes come from a hosted API and its deliveries from off-chain solvers paying out on real Gnosis, so no local chain can make a real payment complete. What can be rehearsed is everything around it, against a second local chain: your wallet signs a genuine deposit there, and the Gnosis-side chain's faucet plays the solver.
Where the chain carries the EIP-7702 delegate, as Gnosis mainnet does, the postage calls run as one
atomic transaction. The baked snapshot cannot carry it — a state dump only keeps storage the bake
wrote — so locally they run one at a time until something splices the delegate in, which /dev →
Chain → Create drive to test with does.
That is the payment dialog's built-in method. The other one, Pay with crypto (fund.bzz.limo) —
the default when buying a drive — settles on Gnosis mainnet only, so locally there is nothing for
it to settle against: pick the built-in method to pay on the local chain, or turn on /dev →
Chain → Simulated purchase, which stands in for the widget with a fabricated batch so that
method's own screens stay reachable here. (It is also offered for buying alone; its contract ABI
cannot top up or dilute, so extend and resize list the built-in method by itself.)
pnpm dev:local # everything: cluster, both chains, solver, identity UI, demo
pnpm dev:local:fresh # the same, from a clean chain and empty node state
pnpm dev:local:stop # tear the containers down| What | Where |
|---|---|
| Identity UI | http://localhost:5500 |
| Demo | http://localhost:3500 |
| Queen Bee API | http://localhost:1633 |
| Gnosis-side chain (100) | http://localhost:9545 |
| Payment source chain (31337) | http://localhost:31337 |
The quickest way to rehearse a payment is to pay from Gnosis, which needs no bridge: point the
wallet at http://localhost:9545 (chain 100, offered as Gnosis Chain (fake)) and the payment is
a plain xDAI transfer to the batch owner — no source chain and no solver involved at all. Paying
from Ethereum Mainnet (fake) on :31337 is the bridged route, and that one does need the solver.
The containers run in the background; the solver, UI and demo run in the foreground so you can
watch each delivery land. Re-running dev:local is a no-op for whatever is already up.
/dev → Chain → Wallet networks adds the local chains to MetaMask so a balance shows before
you reach the payment screens, and the Faucet beside it stocks the account you connect with —
Use connected wallet fills its recipient with the address you would be paying from, which is the
one that has to hold something. Nothing funds the payer for you: the wallet must already hold what it
pays with.
Reach for dev:local:fresh when the chain has drifted — every purchase trades against a real,
thin BZZ pool, and this restores the baked snapshot (it also wipes node state, so drives you
created earlier will point at batches that no longer exist; clear the UI's site data too).
Chain id alone cannot tell the local chain from the real one — it answers as 100 deliberately — so before anything is signed the app compares genesis hashes, which a chain cannot borrow, against the endpoint's own. It refuses in words whenever the two are not the same chain: your wallet on real Gnosis while the app is pointed at the local one, the reverse, or a wallet simply left on some third network. That is what stops a rehearsal spending real xDAI.
Already have the real Gnosis in MetaMask? Remove it while rehearsing. MetaMask keys networks by chain id, so a switch to 100 lands on whichever RPC is active for it — usually the real one. The app detects the mismatch by genesis and offers its own RPC, but MetaMask refuses to adopt an RPC for an id it already serves ("network already exists"), so the offer cannot repair it for you: remove the real Gnosis network from MetaMask first (or select the local RPC by hand in that network's menu), and add it back when you are done. The fake mainnet has no such trap — 31337 collides with nothing.
Then, once: open the UI → Settings → Network settings → Use local → Save.
Where the solver fits. The browser signs the deposit and then waits for money it does not
control, exactly as it waits on Relay; multichain/src/local-solver.ts is what watches the source
chain and pays out from the Gnosis-side faucet. The deposit carries its own delivery instruction in
its calldata, so the solver is stateless. Stop the solver and a payment hangs and then fails —
which is what a solver outage looks like.
What this does and does not prove. The Gnosis side is genuine — the delivered xDAI is swapped for BZZ through a real SushiSwap pool and spent against the real PostageStamp contract, as a single atomic EIP-7702 transaction using the same delegate contract mainnet uses. The rail itself is not: its prices are invented, and its failure and refund behaviour is nothing like the real one. The step shape is mirrored — paying in ETH is one signature, paying in the mock USDC is approve-then-deposit with the solver pulling the token, as on Relay. It rehearses the payment experience — connect, switch chain, quote, approve, progress, cancel, resume — which is otherwise untestable outside production.
With no source chain running the bridged route simply is not offered; paying from Gnosis still is, and it needs no solver. Funding never falls back to a free transfer. See docs/Drive-Payment-Flow.md.
The Identity UI includes a Developer Tools page at http://localhost:5500/dev with utilities for local development:
A menu in the header switches the whole app between the local and production endpoints, and a banner names the chain the configured RPC actually serves — so it is always visible whether these tools would be spending real money.
- Overview: Live endpoint status, a link into the demo app's connect flow, and local-data counters
- Chain: Faucet, on-chain batch and test-drive creation, batch import by ID, and the mock purchase toggles for the Add drive flow
- Node: Stored stamps, retrievability checks, manual sync, and partition tuning
- Devices: The devices registered to an account and the partitions they hold
.
├── lib/ # @snaha/swarm-id TypeScript library
├── ui/ # Identity UI (SvelteKit + Tailwind v4 + shadcn-svelte style)
├── demo/ # Demo app (SvelteKit)
├── signaling/ # Account-bus signaling and relay server (ws)
└── docs-site/ # Documentation website (Starlight/Astro)
- Check if ports 3500 and 5500 are already in use:
lsof -i :3500 -i :5500 - Ensure both servers are running:
pnpm dev
- Allow popups for localhost in browser settings
- Ensure popup is triggered by user action (not programmatically)
- Library changes: restart
pnpm dev:libor rebuild - SvelteKit changes: automatic hot reload
Safari's Intelligent Tracking Prevention (ITP) partitions storage for third-party iframes, so the proxy cannot read the trusted domain's localStorage. Uploads are designed to keep working regardless: the connect popup hands the iframe the account's synced projection (postage stamps including their signer keys), and cross-context coordination rides the account bus instead of storage events.
Status: confirmed on real Safari, upload included. Measured on iOS 18.7 / Safari 26.6 against the deployed sites (
swarm-demo.snaha.net→swarm-id.snaha.net): ITP partitions the proxy iframe; the connect popup'spostMessagethroughwindow.openerreaches it; the hydrated account view builds a working stamper (uploadMode: user-stamp); and a chunk uploads and reads back byte-identical. The device id also held across a reload, so the partitioned store survived the session ending and being re-seeded (#584 has the report).A private window was measured separately and passes the same five checks, upload included — a fresh device id each time, as expected, since the private partition is discarded when the window closes.
One thing no run has settled, and it is not claimed here: the eviction horizon — two loads in one sitting says nothing about whether a dormant account's partitioned storage survives ITP's ~30-day window (#570).
What remains regardless:
- The session outlives the page. The handover is kept in the partition's own store until the user disconnects or the 30-day session expires, so a reload does not re-run the popup (#635). A restored session reads the account's published state once in the background and ends itself if this app was revoked while the tab was closed — that reaches it no other way. An account with no drive has published nothing to check against, so there the revoke waits for the next time both contexts are open.
- Live propagation needs a signaling server. The DigitalOcean build has one (
wss://swarm-id.snaha.net/bus) andpnpm devstarts one locally; a build without one configured (GitHub Pages) leaves a partitioned iframe talking only to contexts in its own partition. How the bus works: docs/Account-Bus.md. - Safari private mode: Sessions are ephemeral (lost when the private window closes).
See #277 for the background.
We have a separate guide document if you want to contribute to the project.