Skip to content

Latest commit

 

History

History
319 lines (229 loc) · 16.2 KB

File metadata and controls

319 lines (229 loc) · 16.2 KB

Swarm Identity Management

Cross-browser compatible authentication and identity management for Swarm dApps.

Documentation | Identity UI | Demo

Packages

  • lib/@snaha/swarm-id TypeScript library for authentication and Bee API operations
  • ui/@swarm-id/ui SvelteKit identity UI (trusted domain)
  • demo/ — Demo dApp with library integration examples
  • docs-site/ — Starlight (Astro) documentation website
  • signaling/@swarm-id/signaling account-bus signaling and relay server

Architecture

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.

Architecture deep-dive →

Quick Start

pnpm add @snaha/swarm-id
import { 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()

Full integration guide →

Deployment

GitHub Pages — swarm.snaha.net

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

DigitalOcean App Platform (canonical domains)

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-signaling service of the same app; the UI build points at it through PUBLIC_BUS_SIGNALING_URL

Local Development

pnpm install
pnpm dev

Open 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 (localhost is 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.

Development Mode (with hot reload)

# 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)

Local Bee Cluster (bee-compose)

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 --fresh

Endpoints:

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:

  1. Navigate to http://localhost:5500/dev
  2. Go to the Chain tab and select an account
  3. 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.

Paying for storage locally

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 /devChainCreate 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 /devChainSimulated 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.

/devChainWallet 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 → SettingsNetwork settingsUse localSave.

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.

Developer Tools (/dev route)

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

Project Structure

.
├── 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)

Troubleshooting

Demo not loading

  • Check if ports 3500 and 5500 are already in use: lsof -i :3500 -i :5500
  • Ensure both servers are running: pnpm dev

Authentication popup blocked

  • Allow popups for localhost in browser settings
  • Ensure popup is triggered by user action (not programmatically)

Changes not reflecting

  • Library changes: restart pnpm dev:lib or rebuild
  • SvelteKit changes: automatic hot reload

Safari limitations

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.netswarm-id.snaha.net): ITP partitions the proxy iframe; the connect popup's postMessage through window.opener reaches 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) and pnpm dev starts 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.

Contribute

We have a separate guide document if you want to contribute to the project.

License

Apache 2.0