···15 "github.com/ipfs/go-cid"
16)
1718-// IPFSBlockstore stores and retrieves blocks via a Kubo (go-ipfs) node using
19-// its HTTP RPC API. It implements the boxo blockstore.Blockstore interface so
20-// it can be used as a drop-in replacement for the SQLite-backed store.
21-//
22-// Blocks are written to IPFS via /api/v0/block/put and read back via
23-// /api/v0/block/get. A local in-memory cache of pending writes is kept so
24-// that blocks are immediately readable within the same commit cycle before
25-// the IPFS node has finished processing them.
26type IPFSBlockstore struct {
27 nodeURL string
28 did string
···33 inserts map[cid.Cid]blocks.Block
34}
3536-// NewIPFS creates a new IPFSBlockstore that talks to the Kubo node at nodeURL.
37func NewIPFS(did string, nodeURL string, cli *http.Client) *IPFSBlockstore {
38 if nodeURL == "" {
39 nodeURL = "http://127.0.0.1:5001"
···49 }
50}
5152-// SetRev sets the revision string. This satisfies the revSetter interface used
53-// by commitRepo so that the blockstore is compatible with the repo commit flow.
54func (bs *IPFSBlockstore) SetRev(rev string) {
55 bs.rev = rev
56}
5758-// Get retrieves a block by CID. It first checks the local write cache and
59-// falls back to the IPFS node.
60func (bs *IPFSBlockstore) Get(ctx context.Context, c cid.Cid) (blocks.Block, error) {
61 bs.mu.RLock()
62 if blk, ok := bs.inserts[c]; ok {
···96 return blk, nil
97}
9899-// Put writes a single block to the IPFS node and caches it locally.
100func (bs *IPFSBlockstore) Put(ctx context.Context, block blocks.Block) error {
101 bs.mu.Lock()
102 bs.inserts[block.Cid()] = block
···109 return nil
110}
111112-// PutMany writes multiple blocks to the IPFS node in sequence.
113func (bs *IPFSBlockstore) PutMany(ctx context.Context, blks []blocks.Block) error {
114 for _, blk := range blks {
115 bs.mu.Lock()
···124}
125126func (bs *IPFSBlockstore) putToIPFS(ctx context.Context, blk blocks.Block) error {
127- // Use /api/v0/block/put with the correct codec and hash so the IPFS node
128- // stores the block under the exact same CID we computed locally.
129 pref := blk.Cid().Prefix()
130131 codecName, err := codecToName(pref.Codec)
···175 return fmt.Errorf("ipfs block/put returned %d: %s", resp.StatusCode, string(msg))
176 }
177178- // Verify the CID returned by the node matches what we expect.
179 var result struct {
180 Key string `json:"Key"`
181 Size int `json:"Size"`
···196 return nil
197}
198199-// Has checks the local cache first, then asks the IPFS node.
200func (bs *IPFSBlockstore) Has(ctx context.Context, c cid.Cid) (bool, error) {
201 bs.mu.RLock()
202 if _, ok := bs.inserts[c]; ok {
···221 return resp.StatusCode == http.StatusOK, nil
222}
223224-// GetSize returns the size of the block data.
225func (bs *IPFSBlockstore) GetSize(ctx context.Context, c cid.Cid) (int, error) {
226 blk, err := bs.Get(ctx, c)
227 if err != nil {
···230 return len(blk.RawData()), nil
231}
232233-// DeleteBlock is a no-op for IPFS; blocks are garbage-collected by the node.
234func (bs *IPFSBlockstore) DeleteBlock(ctx context.Context, c cid.Cid) error {
235 bs.mu.Lock()
236 delete(bs.inserts, c)
237 bs.mu.Unlock()
238239- // Attempt to unpin; ignore errors since the block may not be pinned
240- // individually (it could be part of a DAG pin).
241 endpoint := fmt.Sprintf("%s/api/v0/pin/rm?arg=%s", bs.nodeURL, c.String())
242 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
243 if err != nil {
···253 return nil
254}
255256-// AllKeysChan is not supported on the IPFS blockstore.
257func (bs *IPFSBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) {
258 return nil, fmt.Errorf("iteration not supported on IPFS blockstore")
259}
···261// HashOnRead is a no-op.
262func (bs *IPFSBlockstore) HashOnRead(bool) {}
263264-// GetWriteLog returns the blocks written during this session, matching the
265-// interface used by RecordingBlockstore for firehose event construction.
266func (bs *IPFSBlockstore) GetWriteLog() map[cid.Cid]blocks.Block {
267 bs.mu.RLock()
268 defer bs.mu.RUnlock()
···272 return out
273}
274275-// codecToName converts a CID codec number to the string name expected by the
276-// Kubo /api/v0/block/put endpoint.
277func codecToName(codec uint64) (string, error) {
278 switch codec {
279 case cid.DagCBOR:
···289 }
290}
291292-// mhtypeToName converts a multihash type code to its string name.
293func mhtypeToName(mhtype uint64) (string, error) {
294 switch mhtype {
295 case 0x12: // sha2-256
···15 "github.com/ipfs/go-cid"
16)
1718+// IPFSBlockstore stores blocks through Kubo.
000000019type IPFSBlockstore struct {
20 nodeURL string
21 did string
···26 inserts map[cid.Cid]blocks.Block
27}
2829+// NewIPFS creates a blockstore.
30func NewIPFS(did string, nodeURL string, cli *http.Client) *IPFSBlockstore {
31 if nodeURL == "" {
32 nodeURL = "http://127.0.0.1:5001"
···42 }
43}
4445+// SetRev stores the revision.
046func (bs *IPFSBlockstore) SetRev(rev string) {
47 bs.rev = rev
48}
4950+// Get returns a block by CID.
051func (bs *IPFSBlockstore) Get(ctx context.Context, c cid.Cid) (blocks.Block, error) {
52 bs.mu.RLock()
53 if blk, ok := bs.inserts[c]; ok {
···87 return blk, nil
88}
8990+// Put stores one block.
91func (bs *IPFSBlockstore) Put(ctx context.Context, block blocks.Block) error {
92 bs.mu.Lock()
93 bs.inserts[block.Cid()] = block
···100 return nil
101}
102103+// PutMany stores multiple blocks.
104func (bs *IPFSBlockstore) PutMany(ctx context.Context, blks []blocks.Block) error {
105 for _, blk := range blks {
106 bs.mu.Lock()
···115}
116117func (bs *IPFSBlockstore) putToIPFS(ctx context.Context, blk blocks.Block) error {
118+ // Keep the same CID.
0119 pref := blk.Cid().Prefix()
120121 codecName, err := codecToName(pref.Codec)
···165 return fmt.Errorf("ipfs block/put returned %d: %s", resp.StatusCode, string(msg))
166 }
167168+ // Verify the returned CID.
169 var result struct {
170 Key string `json:"Key"`
171 Size int `json:"Size"`
···186 return nil
187}
188189+// Has reports whether a block exists.
190func (bs *IPFSBlockstore) Has(ctx context.Context, c cid.Cid) (bool, error) {
191 bs.mu.RLock()
192 if _, ok := bs.inserts[c]; ok {
···211 return resp.StatusCode == http.StatusOK, nil
212}
213214+// GetSize returns the size.
215func (bs *IPFSBlockstore) GetSize(ctx context.Context, c cid.Cid) (int, error) {
216 blk, err := bs.Get(ctx, c)
217 if err != nil {
···220 return len(blk.RawData()), nil
221}
222223+// DeleteBlock removes a block from the cache and unpins it.
224func (bs *IPFSBlockstore) DeleteBlock(ctx context.Context, c cid.Cid) error {
225 bs.mu.Lock()
226 delete(bs.inserts, c)
227 bs.mu.Unlock()
228229+ // Ignore unpin errors.
0230 endpoint := fmt.Sprintf("%s/api/v0/pin/rm?arg=%s", bs.nodeURL, c.String())
231 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
232 if err != nil {
···242 return nil
243}
244245+// AllKeysChan is unsupported.
246func (bs *IPFSBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) {
247 return nil, fmt.Errorf("iteration not supported on IPFS blockstore")
248}
···250// HashOnRead is a no-op.
251func (bs *IPFSBlockstore) HashOnRead(bool) {}
252253+// GetWriteLog returns written blocks.
0254func (bs *IPFSBlockstore) GetWriteLog() map[cid.Cid]blocks.Block {
255 bs.mu.RLock()
256 defer bs.mu.RUnlock()
···260 return out
261}
262263+// codecToName converts a CID codec to a Kubo name.
0264func codecToName(codec uint64) (string, error) {
265 switch codec {
266 case cid.DagCBOR:
···276 }
277}
278279+// mhtypeToName converts a multihash type to its name.
280func mhtypeToName(mhtype uint64) (string, error) {
281 switch mhtype {
282 case 0x12: // sha2-256
+1-2
blockstore/recording.go
···9 "github.com/ipfs/go-cid"
10)
1112-// RecordingBlockstore wraps a Blockstore and records all reads and writes
13-// performed against it, for later inspection.
14type RecordingBlockstore struct {
15 base boxoblockstore.Blockstore
16
···9 "github.com/ipfs/go-cid"
10)
1112+// RecordingBlockstore records blockstore reads and writes.
013type RecordingBlockstore struct {
14 base boxoblockstore.Blockstore
15
+21-29
docker-compose.yaml
···5 dockerfile: Dockerfile
6 container_name: vow-init-keys
7 volumes:
8- - ./keys:/keys
9- - ./data:/data/vow
10- - ./init-keys.sh:/init-keys.sh:ro
11 environment:
12 VOW_DID: ${VOW_DID}
13 VOW_HOSTNAME: ${VOW_HOSTNAME}
···25 volumes:
26 - ipfs_data:/data/ipfs
27 environment:
28- # "server" profile disables mDNS/LAN discovery, appropriate for a
29- # server deployment.
30 IPFS_PROFILE: server
31 ports:
32- # Expose the IPFS gateway so a reverse proxy can serve blobs over HTTPS.
33- # Bound to 127.0.0.1 — not reachable from the public internet directly.
34 - "127.0.0.1:8081:8080"
35- # The RPC API (port 5001) is intentionally NOT exposed to the host.
36- # Only the vow container reaches it over the internal Docker network.
37 restart: unless-stopped
38 healthcheck:
39 test: ["CMD", "ipfs", "id"]
···55 ports:
56 - "127.0.0.1:8080:8080"
57 volumes:
58- - ./data:/data/vow
59- - ./keys/rotation.key:/keys/rotation.key:ro
60- - ./keys/jwk.key:/keys/jwk.key:ro
61 environment:
62- # ── Required ────────────────────────────────────────────────────────
63 VOW_DID: ${VOW_DID}
64 VOW_HOSTNAME: ${VOW_HOSTNAME}
65 VOW_ROTATION_KEY_PATH: /keys/rotation.key
···69 VOW_ADMIN_PASSWORD: ${VOW_ADMIN_PASSWORD}
70 VOW_SESSION_SECRET: ${VOW_SESSION_SECRET}
7172- # ── Server ──────────────────────────────────────────────────────────
73 VOW_ADDR: ":8080"
74 VOW_DB_NAME: ${VOW_DB_NAME:-/data/vow/vow.db}
7576- # ── SMTP (optional) ─────────────────────────────────────────────────
77 VOW_SMTP_USER: ${VOW_SMTP_USER:-}
78 VOW_SMTP_PASS: ${VOW_SMTP_PASS:-}
79 VOW_SMTP_HOST: ${VOW_SMTP_HOST:-}
···81 VOW_SMTP_EMAIL: ${VOW_SMTP_EMAIL:-}
82 VOW_SMTP_NAME: ${VOW_SMTP_NAME:-}
8384- # ── IPFS ────────────────────────────────────────────────────────────
85- # RPC API resolves to the ipfs service over the internal Docker network.
86 VOW_IPFS_NODE_URL: ${VOW_IPFS_NODE_URL:-http://ipfs:5001}
87- # Public gateway URL for sync.getBlob redirects. Leave empty to have
88- # vow proxy blob content itself.
89 VOW_IPFS_GATEWAY_URL: ${VOW_IPFS_GATEWAY_URL:-}
90- # Optional x402-gated remote pinning. When set, accounts with x402
91- # pinning enabled will have blobs additionally pinned here after local
92- # storage, with payment signed by the user's Ethereum wallet via the
93- # browser-based signer.
94 VOW_X402_PIN_URL: ${VOW_X402_PIN_URL:-}
95- # CAIP-2 chain identifier for the x402 pinning service.
96- # Defaults to Base Mainnet (eip155:8453).
97 VOW_X402_NETWORK: ${VOW_X402_NETWORK:-eip155:8453}
9899- # ── Misc (optional) ─────────────────────────────────────────────────
100 VOW_FALLBACK_PROXY: ${VOW_FALLBACK_PROXY:-}
101 restart: unless-stopped
102 healthcheck:
···113 container_name: vow-create-invite
114 network_mode: "service:vow"
115 volumes:
116- - ./keys:/keys
117- - ./data:/data/vow
118- - ./create-initial-invite.sh:/create-initial-invite.sh:ro
119 environment:
120 VOW_DID: ${VOW_DID}
121 VOW_HOSTNAME: ${VOW_HOSTNAME}
···89type contextKey string
1011-// SkipCacheKey is the context key used to bypass the passport cache.
12const SkipCacheKey contextKey = "skip-cache"
1314type BackingCache interface {
···12 "github.com/lestrrat-go/jwx/v2/jwk"
13)
1415-// This will confirm to the regex in the application if 5 chars are used for each side of the -
16-// /^[A-Z2-7]{5}-[A-Z2-7]{5}$/
17var letters = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
1819func writeJSON(w http.ResponseWriter, status int, v any) {
···12 "github.com/lestrrat-go/jwx/v2/jwk"
13)
1415+// Invite code alphabet.
016var letters = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
1718func writeJSON(w http.ResponseWriter, status int, v any) {
+4-15
models/models.go
···36 X402PinningEnabled bool `gorm:"default:false"`
37}
3839-// EthereumAddress derives the EIP-55 checksummed Ethereum address from the
40-// stored compressed secp256k1 public key. Returns an empty string if no
41-// public key has been registered yet.
42-//
43-// The derivation is: decompress pubkey → keccak256(pubkey[1:]) → take last 20 bytes.
44-// This is the same address the user's Ethereum wallet (Rabby, MetaMask, etc.)
45-// will present, so it can be passed as the "from" field in EIP-3009 payment
46-// authorisations without any separate storage.
47func (r *Repo) EthereumAddress() string {
48 if len(r.PublicKey) == 0 {
49 return ""
···100 ID string `gorm:"primaryKey"`
101 Did string `gorm:"index"`
102 PayloadHash string
103- // Payload is the canonical bytes that the client must sign.
104 Payload []byte
105- // Data holds the original serialised write request so it can be replayed
106- // after signature verification.
107 Data []byte
108- // CommitData holds a JSON-serialised pendingCommitState that contains
109- // everything needed to finalise the commit once the signature arrives:
110- // the unsigned commit CBOR, MST diff blocks, record entries, firehose ops,
111- // and per-op results. It is nil for PLC-operation pending writes.
112 CommitData []byte
113 CreatedAt time.Time
114 ExpiresAt time.Time `gorm:"index"`
···36 X402PinningEnabled bool `gorm:"default:false"`
37}
3839+// EthereumAddress returns the Ethereum address for PublicKey.
000000040func (r *Repo) EthereumAddress() string {
41 if len(r.PublicKey) == 0 {
42 return ""
···93 ID string `gorm:"primaryKey"`
94 Did string `gorm:"index"`
95 PayloadHash string
96+ // Bytes to sign.
97 Payload []byte
98+ // Original request.
099 Data []byte
100+ // Serialized commit state.
000101 CommitData []byte
102 CreatedAt time.Time
103 ExpiresAt time.Time `gorm:"index"`
+2-5
oauth/client/manager.go
···18 "pkg.rbrt.fr/vow/internal/helpers"
19)
2021-// supportedScopes lists the OAuth scopes this server accepts.
22var supportedScopes = []string{"atproto", "transition:generic", "transition:chat.bsky"}
2324type Manager struct {
···165 return jwks, nil
166}
167168-// selectKey picks the best signing key from a raw JWKS key list.
169-// It prefers a key whose "kid" matches the hint (if non-empty), then any key
170-// with "use"="sig", and finally falls back to the first key in the set.
171func selectKey(keys []any, kidHint string) (jwk.Key, error) {
172 if len(keys) == 0 {
173 return nil, errors.New("empty jwks")
···303 case "implicit":
304 return nil, errors.New("grant type `implicit` is not allowed")
305 case "authorization_code", "refresh_token":
306- // supported
307 default:
308 return nil, fmt.Errorf("grant type `%s` is not supported", gt)
309 }
···18 "pkg.rbrt.fr/vow/internal/helpers"
19)
2021+// supportedScopes lists accepted OAuth scopes.
22var supportedScopes = []string{"atproto", "transition:generic", "transition:chat.bsky"}
2324type Manager struct {
···165 return jwks, nil
166}
167168+// selectKey picks a signing key from a JWKS list.
00169func selectKey(keys []any, kidHint string) (jwk.Key, error) {
170 if len(keys) == 0 {
171 return nil, errors.New("empty jwks")
···301 case "implicit":
302 return nil, errors.New("grant type `implicit` is not allowed")
303 case "authorization_code", "refresh_token":
0304 default:
305 return nil, fmt.Errorf("grant type `%s` is not supported", gt)
306 }
···89 return c.createDidCredentialsFromPublicKey(pubsigkey, recovery, handle)
90}
9192-// CreateDidCredentialsFromPublicKey builds a DidCredentials struct from an
93-// already-parsed public key. This is used on the BYOK path where the PDS only
94-// holds the public key and must never touch a private key.
95func (c *Client) CreateDidCredentialsFromPublicKey(pubsigkey atcrypto.PublicKey, recovery string, handle string) (*DidCredentials, error) {
96 return c.createDidCredentialsFromPublicKey(pubsigkey, recovery, handle)
97}
···102 return nil, err
103 }
104105- // todo
106 rotationKeys := []string{pubrotkey.DIDKey()}
107 if recovery != "" {
108 rotationKeys = func(recovery string) []string {
···89 return c.createDidCredentialsFromPublicKey(pubsigkey, recovery, handle)
90}
9192+// CreateDidCredentialsFromPublicKey builds DID credentials from a public key.
0093func (c *Client) CreateDidCredentialsFromPublicKey(pubsigkey atcrypto.PublicKey, recovery string, handle string) (*DidCredentials, error) {
94 return c.createDidCredentialsFromPublicKey(pubsigkey, recovery, handle)
95}
···100 return nil, err
101 }
102103+ // Put the recovery key first when present.
104 rotationKeys := []string{pubrotkey.DIDKey()}
105 if recovery != "" {
106 rotationKeys = func(recovery string) []string {
+39-39
readme.md
···3> [!WARNING]
4> This is highly experimental software. Use with caution, especially during account migration.
56-Vow is a PDS (Personal Data Server) implementation in Go for the AT Protocol.
78## Features
910-- ✅ **IPFS storage** — all repo blocks and blobs stored on a co-located Kubo node, indexed in SQLite by DID and CID.
11-- ✅ **Keyless PDS** — the server never holds a private key. Every write is signed by the user's own secp256k1 key, held exclusively in their Ethereum wallet (Rabby, MetaMask, etc.).
12-- ✅ **Browser-based signer** — the PDS account page connects to the server over WebSocket and signs commits directly via the user's Ethereum wallet. No browser extension needed — just keep the tab open. Standard ATProto clients are completely unaware of this.
13-- ✅ **User-sovereign DID** — at key registration the PDS transfers the `did:plc` rotation key to the user's wallet. After that, only the user can modify their identity — the PDS can never hijack it, and migration to another PDS requires no permission.
14-- 🔜 **x402 payments for IPFS storage** — blob uploads gated behind on-chain payments via the [x402 protocol](https://x402.org), using the same Ethereum wallet.
1516## Quick Start with Docker Compose
17···58 ```
5960 This starts three services:
61- - **ipfs** — a Kubo node storing all repo blocks and blobs
62- - **vow** — the PDS, wired to the Kubo node automatically
63 - **create-invite** — creates an initial invite code on first run
64655. **Get your invite code**
···8384### What Gets Set Up
8586-- **init-keys**: Generates cryptographic keys (rotation key and JWK) on first run
87-- **ipfs**: A Kubo IPFS node storing all content. The RPC API (port 5001) is internal only; the gateway (port 8080) is published on `127.0.0.1:8081` for your reverse proxy.
88-- **vow**: The main PDS service running on port 8080
89- **create-invite**: Creates an initial invite code on first run
9091### Data Persistence
9293-- `./keys/` — Cryptographic keys (generated automatically)
94 - `rotation.key` — PDS rotation key
95 - `jwk.key` — JWK private key
96- - `initial-invite-code.txt` — Your first invite code (first run only)
97-- `./data/` — SQLite database (metadata only)
98-- `ipfs_data` Docker volume — all IPFS blocks and blobs
99100### Reverse Proxy
101102-You will need a reverse proxy (nginx, Caddy, etc.) in front of both services:
103104| Service | Internal address | Purpose |
105| ------- | ---------------- | ----------------------------- |
106| vow | `127.0.0.1:8080` | AT Protocol PDS |
107| ipfs | `127.0.0.1:8081` | IPFS gateway for blob serving |
108109-Set `VOW_IPFS_GATEWAY_URL` to your public-facing gateway URL so that `sync.getBlob` redirects clients to the gateway directly instead of proxying through vow.
110111## Configuration
112113### Database
114115-Vow uses SQLite for relational metadata (accounts, sessions, records index, tokens, etc.). No additional setup is required.
116117```bash
118VOW_DB_NAME="/data/vow/vow.db"
···120121### IPFS Node
122123-The co-located Kubo node is configured automatically in Docker Compose via the internal service hostname `ipfs`. For bare-metal deployments, point vow at your local node:
124125```bash
126# URL of the Kubo RPC API
···131VOW_IPFS_GATEWAY_URL="https://ipfs.example.com"
132```
133134-`VOW_IPFS_NODE_URL` is the only required IPFS setting. The co-located Kubo node is the sole storage backend — remote pinning will be wired in later via x402 payments.
135136### SMTP Email
137···146147### BYOK (Bring Your Own Key)
148149-The PDS **never stores or touches a private key**. Every write operation — whether it originates from the Bluesky app, Tangled, or any other standard ATProto client — is held open by the PDS until the user's Ethereum wallet provides a signature, delivered via the browser-based signer running on the account page.
150151#### End-to-end signing flow
152153-This flow is entirely transparent to standard ATProto clients. The PDS holds the inbound HTTP request open while it waits for the signature, then responds normally.
154155```
156ATProto client PDS (vow) Account page (browser) Ethereum wallet
···169170**What requires a wallet signature:**
171172-Only operations that modify the user's repo or identity require a round-trip to the wallet:
173174- **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites`
175- **Identity operations** — PLC operations, handle updates
176- **x402 payments** — EIP-712 payment authorisations for gated pinning
177178-Read-only operations (browsing feeds, loading profiles, fetching notifications, etc.) do **not** prompt the wallet. The PDS proxies these to the AppView using cached service-auth JWTs — see [Service auth caching](#service-auth-caching) below.
179180#### Service auth caching
181182-In standard ATProto the PDS signs service-auth JWTs transparently because it holds the signing key. In Vow the signing key lives in the user's wallet, so naively every proxied request (feed loads, profile views, notifications…) would trigger a wallet prompt — making the PDS unusable for browsing.
183184-Vow solves this by **caching service-auth JWTs**. When the proxy needs a token for a given `(aud, lxm)` pair, it first checks an in-memory cache. Only on a cache miss does it send a signing request to the wallet. Cached tokens have a 30-minute lifetime with a 15-second reuse margin, so in practice the wallet is prompted at most **once every ~30 minutes per remote service endpoint** rather than on every request.
185186Tokens requested explicitly via `com.atproto.server.getServiceAuth` (where the caller controls the expiry) bypass the cache and always go to the wallet.
187188#### WebSocket connection
189190-The account page connects using the session cookie (set at sign-in):
191192```
193GET /account/signer
194Cookie: <session-cookie>
195```
196197-The connection is upgraded to a WebSocket and kept alive with standard ping/pong. Only one active connection per DID is supported; a new connection replaces the previous one. The connection persists as long as the tab is open and reconnects automatically with exponential back-off if interrupted.
198199A legacy Bearer-token endpoint is also available for programmatic clients:
200···239240### Browser-Based Signer
241242-The signer runs entirely within the PDS account page — no browser extension or additional software is needed. The user keeps the account page open (a pinned tab works well) and all signing happens automatically.
243244## Identity & DID Sovereignty
245246-Vow uses `did:plc` for full ATProto federation compatibility — AppViews, relays, and other PDSes resolve DIDs through `plc.directory` without any custom logic. The key difference from a standard PDS is **who controls the rotation key**.
247248### The problem with standard ATProto
249250-In a typical PDS, the server holds the `did:plc` rotation key. This means the PDS operator can unilaterally modify the user's DID document — rotating signing keys, changing service endpoints, or effectively hijacking the identity. The user must trust the operator not to do this.
251252### Vow's approach: trust-then-transfer
253254Vow uses a two-phase model:
255256-**Phase 1 — Account creation (trust the PDS).** The user is signing up on a PDS they chose, so they implicitly trust it at this moment. `createAccount` works like standard ATProto: the PDS creates the `did:plc` with its own rotation key.
257258-**Phase 2 — Key registration (sovereignty transfer).** When the user completes onboarding and calls `supplySigningKey`, the PDS submits a single PLC operation that sets the user's wallet key as both the signing key and the **sole rotation key**, removing the PDS's own key. After this operation the PDS can never modify the DID document again — only the user's Ethereum wallet can authorise future PLC operations.
259260-### What the user gains
261262| Property | Before key registration | After key registration |
263| --------------------------- | -------------------------- | ------------------------------------------------- |
···269270### Verifiability
271272-The transfer is verifiable by anyone. The PLC audit log at `plc.directory` shows the full history of rotation key changes. After `supplySigningKey` completes, the log will show the PDS rotation key being replaced by the user's wallet `did:key`. Any third party can inspect this and confirm the user controls their own identity.
273274### Why not `did:key` or on-chain?
275276-- **`did:key`** — the DID _is_ the public key, which is elegant but no existing ATProto AppView or relay resolves it. Adopting it would require changes across the entire federation.
277-- **On-chain registry** — fully trustless, but forces every PDS implementation to integrate with a blockchain.
278-- **`did:web`** — if hosted on the PDS domain, the PDS controls it. If hosted on the user's domain, most users don't have one.
279280-`did:plc` with rotation key transfer is the pragmatic choice: it works with every existing ATProto implementation today and gives users real sovereignty over their identity.
281282## Management Commands
283
···3> [!WARNING]
4> This is highly experimental software. Use with caution, especially during account migration.
56+Vow is a Go PDS (Personal Data Server) for the AT Protocol.
78## Features
910+- ✅ **IPFS storage** — repo blocks and blobs are stored on a local Kubo node and indexed in SQLite by DID and CID.
11+- ✅ **Keyless PDS** — the server never stores a private key. Every write is signed by the user's secp256k1 key in their Ethereum wallet (Rabby, MetaMask, etc.).
12+- ✅ **Browser signer** — the account page connects over WebSocket and signs commits with the user's Ethereum wallet. No browser extension is needed; just keep the tab open. Standard ATProto clients do not need to know about it.
13+- ✅ **User-controlled DID** — when the user registers a key, the PDS transfers the `did:plc` rotation key to the user's wallet. After that, only the user can change their identity.
14+- 🔜 **x402 payments for IPFS storage** — blob uploads will be gated by on-chain payments via the [x402 protocol](https://x402.org), using the same Ethereum wallet.
1516## Quick Start with Docker Compose
17···58 ```
5960 This starts three services:
61+ - **ipfs** — a Kubo node for repo blocks and blobs
62+ - **vow** — the PDS
63 - **create-invite** — creates an initial invite code on first run
64655. **Get your invite code**
···8384### What Gets Set Up
8586+- **init-keys**: Generates the rotation key and JWK on first run
87+- **ipfs**: A Kubo node for repo blocks and blobs. The RPC API (port 5001) stays internal; the gateway (port 8080) is exposed on `127.0.0.1:8081` for your reverse proxy.
88+- **vow**: The main PDS service on port 8080
89- **create-invite**: Creates an initial invite code on first run
9091### Data Persistence
9293+- `./keys/` — generated keys
94 - `rotation.key` — PDS rotation key
95 - `jwk.key` — JWK private key
96+ - `initial-invite-code.txt` — first invite code (first run only)
97+- `./data/` — SQLite metadata database
98+- `ipfs_data` Docker volume — IPFS blocks and blobs
99100### Reverse Proxy
101102+You need a reverse proxy (nginx, Caddy, etc.) in front of both services:
103104| Service | Internal address | Purpose |
105| ------- | ---------------- | ----------------------------- |
106| vow | `127.0.0.1:8080` | AT Protocol PDS |
107| ipfs | `127.0.0.1:8081` | IPFS gateway for blob serving |
108109+Set `VOW_IPFS_GATEWAY_URL` to your public gateway URL so `sync.getBlob` redirects clients there instead of proxying through vow.
110111## Configuration
112113### Database
114115+Vow uses SQLite for relational metadata such as accounts, sessions, record indexes, and tokens. No extra setup is required.
116117```bash
118VOW_DB_NAME="/data/vow/vow.db"
···120121### IPFS Node
122123+In Docker Compose, the local Kubo node is configured automatically through the internal hostname `ipfs`. For bare-metal deployments, point vow at your local node:
124125```bash
126# URL of the Kubo RPC API
···131VOW_IPFS_GATEWAY_URL="https://ipfs.example.com"
132```
133134+`VOW_IPFS_NODE_URL` is the only required IPFS setting. The local Kubo node is the only storage backend for now; remote pinning will come later through x402 payments.
135136### SMTP Email
137···146147### BYOK (Bring Your Own Key)
148149+The PDS **never stores or uses a private key**. Every write from the Bluesky app, Tangled, or any other standard ATProto client is held open until the user's Ethereum wallet returns a signature through the browser signer on the account page.
150151#### End-to-end signing flow
152153+Standard ATProto clients do not need to know about this flow. The PDS keeps the HTTP request open while it waits for the signature, then responds normally.
154155```
156ATProto client PDS (vow) Account page (browser) Ethereum wallet
···169170**What requires a wallet signature:**
171172+Only operations that change the user's repo or identity need a wallet signature:
173174- **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites`
175- **Identity operations** — PLC operations, handle updates
176- **x402 payments** — EIP-712 payment authorisations for gated pinning
177178+Read-only operations (browsing feeds, loading profiles, fetching notifications, etc.) do **not** prompt the wallet. The PDS proxies them to the AppView using cached service-auth JWTs — see [Service auth caching](#service-auth-caching) below.
179180#### Service auth caching
181182+In standard ATProto, the PDS signs service-auth JWTs itself because it holds the signing key. In Vow, the signing key is in the user's wallet, so without caching, every proxied request (feeds, profiles, notifications, and so on) would trigger a wallet prompt.
183184+Vow solves this by **caching service-auth JWTs**. When the proxy needs a token for an `(aud, lxm)` pair, it checks an in-memory cache first. Only a cache miss triggers a wallet signing request. Cached tokens live for 30 minutes with a 15-second reuse margin, so in practice the wallet is prompted at most **once every ~30 minutes per remote service endpoint** instead of on every request.
185186Tokens requested explicitly via `com.atproto.server.getServiceAuth` (where the caller controls the expiry) bypass the cache and always go to the wallet.
187188#### WebSocket connection
189190+The account page connects with the session cookie set at sign-in:
191192```
193GET /account/signer
194Cookie: <session-cookie>
195```
196197+The connection is upgraded to a WebSocket and kept alive with ping/pong. Only one active connection per DID is supported; a new connection replaces the old one. The connection stays open while the tab is open and reconnects automatically with exponential backoff if interrupted.
198199A legacy Bearer-token endpoint is also available for programmatic clients:
200···239240### Browser-Based Signer
241242+The signer runs entirely in the PDS account page. No browser extension or extra software is needed. The user keeps the page open (a pinned tab works well) and signing happens automatically.
243244## Identity & DID Sovereignty
245246+Vow uses `did:plc` for full ATProto federation compatibility. AppViews, relays, and other PDSes can resolve DIDs through `plc.directory` without custom logic. The main difference from a standard PDS is **who controls the rotation key**.
247248### The problem with standard ATProto
249250+In a typical PDS, the server holds the `did:plc` rotation key. That means the operator can change the user's DID document, rotate signing keys, change service endpoints, or effectively hijack the identity. The user has to trust the operator not to do that.
251252### Vow's approach: trust-then-transfer
253254Vow uses a two-phase model:
255256+**Phase 1 — Account creation.** `createAccount` works like standard ATProto: the PDS creates the `did:plc` with its own rotation key.
257258+**Phase 2 — Key registration.** When the user completes onboarding and calls `supplySigningKey`, the PDS submits one PLC operation that makes the user's wallet key both the signing key and the **only rotation key**, removing the PDS key. After that, only the user's Ethereum wallet can authorise future PLC operations.
259260+### What the user gets
261262| Property | Before key registration | After key registration |
263| --------------------------- | -------------------------- | ------------------------------------------------- |
···269270### Verifiability
271272+The transfer is publicly verifiable. The PLC audit log at `plc.directory` shows the full history of rotation key changes. After `supplySigningKey`, the log shows the PDS rotation key being replaced by the user's wallet `did:key`.
273274### Why not `did:key` or on-chain?
275276+- **`did:key`** — elegant, but current ATProto AppViews and relays do not resolve it.
277+- **On-chain registry** — fully trustless, but every PDS would need blockchain support.
278+- **`did:web`** — if hosted on the PDS domain, the PDS controls it. If hosted on the user's domain, most users do not have one.
279280+`did:plc` with rotation key transfer is the pragmatic choice: it works with existing ATProto implementations today and gives users real control over their identity.
281282## Management Commands
283
+2-5
server/blockstore_factory.go
···4 vowblockstore "pkg.rbrt.fr/vow/blockstore"
5)
67-// newBlockstoreForRepo returns an IPFS-backed blockstore for the given DID.
8-// All repo blocks are stored on and retrieved from the co-located Kubo node.
9func newBlockstoreForRepo(did string, ipfsCfg *IPFSConfig) *vowblockstore.IPFSBlockstore {
10 return vowblockstore.NewIPFS(did, ipfsCfg.NodeURL, nil)
11}
1213-// newRecordingBlockstoreForRepo wraps the IPFS blockstore in a
14-// RecordingBlockstore so that all reads and writes during a commit are tracked
15-// for firehose CAR slice construction.
16func newRecordingBlockstoreForRepo(did string, ipfsCfg *IPFSConfig) (*vowblockstore.RecordingBlockstore, *vowblockstore.IPFSBlockstore) {
17 base := newBlockstoreForRepo(did, ipfsCfg)
18 return vowblockstore.NewRecording(base), base
···4 vowblockstore "pkg.rbrt.fr/vow/blockstore"
5)
67+// newBlockstoreForRepo returns the blockstore for a DID.
08func newBlockstoreForRepo(did string, ipfsCfg *IPFSConfig) *vowblockstore.IPFSBlockstore {
9 return vowblockstore.NewIPFS(did, ipfsCfg.NodeURL, nil)
10}
1112+// newRecordingBlockstoreForRepo adds read/write logging.
0013func newRecordingBlockstoreForRepo(did string, ipfsCfg *IPFSConfig) (*vowblockstore.RecordingBlockstore, *vowblockstore.IPFSBlockstore) {
14 base := newBlockstoreForRepo(did, ipfsCfg)
15 return vowblockstore.NewRecording(base), base
+6-17
server/handle_account_signer.go
···11 "github.com/gorilla/websocket"
12)
1314-// handleAccountSigner is the cookie-authenticated equivalent of handleSignerConnect.
15-// It upgrades the connection to a WebSocket using the web session cookie for
16-// authentication (set by handleAccountSigninPost) instead of requiring a Bearer
17-// token. This allows the account page to act as the signer directly — no
18-// browser extension needed.
19-//
20-// The WebSocket protocol is identical to handleSignerConnect: the PDS pushes
21-// sign_request / pay_request frames and expects sign_response / sign_reject /
22-// pay_response / pay_reject back.
23func (s *Server) handleAccountSigner(w http.ResponseWriter, r *http.Request) {
24 logger := s.logger.With("name", "handleAccountSigner")
2526- // Authenticate via the web session cookie.
27 repo, _, err := s.getSessionRepoOrErr(r)
28 if err != nil {
29 http.Error(w, "Unauthorized", http.StatusUnauthorized)
···31 }
32 did := repo.Repo.Did
3334- // Ensure the account has a public key registered.
35 if len(repo.PublicKey) == 0 {
36 http.Error(w, "no signing key registered for this account", http.StatusBadRequest)
37 return
···4647 logger.Info("browser signer connected", "did", did)
4849- // Register this connection with the hub, evicting any previous connection
50- // for the same DID (e.g. from another tab).
51 sc := s.signerHub.Register(did)
52 defer s.signerHub.Unregister(did, sc)
5354- // Configure read deadline + pong handler for keep-alive.
55 if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
56 logger.Error("signer: failed to set initial read deadline", "did", did, "error", err)
57 return
···63 pingTicker := time.NewTicker(20 * time.Second)
64 defer pingTicker.Stop()
6566- // No token expiry check needed — cookie sessions are long-lived and
67- // validated on each HTTP request. The WebSocket stays open as long as the
68- // browser tab is open.
6970 readErr := make(chan error, 1)
71 inbound := make(chan wsIncoming, 4)
···11 "github.com/gorilla/websocket"
12)
1314+// handleAccountSigner upgrades an account session to a signer WebSocket.
0000000015func (s *Server) handleAccountSigner(w http.ResponseWriter, r *http.Request) {
16 logger := s.logger.With("name", "handleAccountSigner")
1718+ // Authenticate the session.
19 repo, _, err := s.getSessionRepoOrErr(r)
20 if err != nil {
21 http.Error(w, "Unauthorized", http.StatusUnauthorized)
···23 }
24 did := repo.Repo.Did
2526+ // Require a public key.
27 if len(repo.PublicKey) == 0 {
28 http.Error(w, "no signing key registered for this account", http.StatusBadRequest)
29 return
···3839 logger.Info("browser signer connected", "did", did)
4041+ // Replace any existing signer connection for this DID.
042 sc := s.signerHub.Register(did)
43 defer s.signerHub.Unregister(did, sc)
4445+ // Keep the connection alive.
46 if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
47 logger.Error("signer: failed to set initial read deadline", "did", did, "error", err)
48 return
···54 pingTicker := time.NewTicker(20 * time.Second)
55 defer pingTicker.Stop()
5657+ // Session validity was checked during upgrade.
005859 readErr := make(chan error, 1)
60 inbound := make(chan wsIncoming, 4)
+1-1
server/handle_account_signin.go
···101 queryParams = fmt.Sprintf("?%s", req.QueryParams)
102 }
103104- // TODO: we should make this a helper since we do it for the base create_session as well
105 var repo models.RepoActor
106 var err error
107 switch idtype {
···101 queryParams = fmt.Sprintf("?%s", req.QueryParams)
102 }
103104+ // TODO: extract this shared lookup into a helper.
105 var repo models.RepoActor
106 var err error
107 switch idtype {
+6-32
server/handle_account_signup.go
···20 "pkg.rbrt.fr/vow/models"
21)
2223-// handleAccountSignupGet renders the sign-up form. If the user already has a
24-// valid web session they are redirected to the account page instead.
25func (s *Server) handleAccountSignupGet(w http.ResponseWriter, r *http.Request) {
26 _, sess, err := s.getSessionRepoOrErr(r)
27 if err == nil {
···36 s.renderSignupForm(w, r, sess, "", "", "")
37}
3839-// handleAccountSignupPost validates the form input, creates the account (DID,
40-// repo, actor), establishes a web session and redirects to /account so the
41-// user can register their signing key and connect the signer.
42func (s *Server) handleAccountSignupPost(w http.ResponseWriter, r *http.Request) {
43 ctx := r.Context()
44 logger := s.logger.With("name", "handleAccountSignupPost")
···65 s.renderSignupForm(w, r, sess, handle, email, inviteCode)
66 }
6768- // ── Basic validation ─────────────────────────────────────────────────
69-70 if handle == "" || email == "" || password == "" {
71 fail("All fields are required.")
72 return
73 }
7475- // The handle entered in the form is the local part only (e.g. "alice").
76- // Append the server domain to produce the full handle.
77 if !strings.Contains(handle, ".") {
78 handle = handle + "." + s.config.Hostname
79 }
80 handle = strings.ToLower(handle)
8182- // Validate handle syntax.
83 if _, err := syntax.ParseHandle(handle); err != nil {
84 fail("Invalid handle. Use only letters, numbers and hyphens.")
85 return
86 }
8788- // Validate that the handle's domain suffix matches the server hostname.
89 if !strings.HasSuffix(handle, "."+s.config.Hostname) && handle != s.config.Hostname {
90 fail("Handle must be under " + s.config.Hostname + ".")
91 return
···95 fail("Password must be at least 6 characters.")
96 return
97 }
98-99- // ── Invite code ──────────────────────────────────────────────────────
100101 var ic models.InviteCode
102 if s.config.RequireInvite {
···121 }
122 }
123124- // ── Check handle availability ────────────────────────────────────────
125-126 actor, err := s.getActorByHandle(ctx, handle)
127 if err != nil && err != gorm.ErrRecordNotFound {
128 logger.Error("error looking up handle", "error", err)
···139 return
140 }
141142- // ── Check email availability ─────────────────────────────────────────
143-144 existingRepo, err := s.getRepoByEmail(ctx, email)
145 if err != nil && err != gorm.ErrRecordNotFound {
146 logger.Error("error looking up email", "error", err)
···151 fail("That email address is already registered.")
152 return
153 }
154-155- // ── Create DID ───────────────────────────────────────────────────────
156157 k, err := atcrypto.GeneratePrivateKeyK256()
158 if err != nil {
···174 return
175 }
176177- // ── Create repo + actor rows ─────────────────────────────────────────
178-179 hashed, err := bcrypt.GenerateFromPassword([]byte(password), 10)
180 if err != nil {
181 logger.Error("error hashing password", "error", err)
···208 return
209 }
210211- // ── Genesis commit ───────────────────────────────────────────────────
212-213 bs := newBlockstoreForRepo(did, s.ipfsConfig)
214215 clk := syntax.NewTIDClock(0)
···244 logger.Error("failed to add identity event", "error", err)
245 }
246247- // ── Consume invite code ──────────────────────────────────────────────
248-249 if s.config.RequireInvite {
250 if err := s.db.Raw(ctx, "UPDATE invite_codes SET remaining_use_count = remaining_use_count - 1 WHERE code = ?", nil, inviteCode).Scan(&ic).Error; err != nil {
251 logger.Error("error decrementing invite code use count", "error", err)
···260 }
261 }
262263- // ── Send emails ──────────────────────────────────────────────────────
264-265 go func() {
266 if err := s.sendEmailVerification(email, handle, *urepo.EmailVerificationCode); err != nil {
267 logger.Error("error sending email verification", "error", err)
···270 logger.Error("error sending welcome email", "error", err)
271 }
272 }()
273-274- // ── Establish web session and redirect ───────────────────────────────
275276 sess.Options = &sessions.Options{
277 Path: "/",
···295 }
296}
297298-// renderSignupForm renders the signup.html template with the current form
299-// state and any flash messages. It is used by both the GET handler and the
300-// POST handler (on validation failure) to avoid duplicating template data.
301func (s *Server) renderSignupForm(w http.ResponseWriter, r *http.Request, sess *sessions.Session, handle, email, inviteCode string) {
302 if err := s.renderTemplate(w, "signup.html", map[string]any{
303 "Hostname": s.config.Hostname,
···20 "pkg.rbrt.fr/vow/models"
21)
2223+// handleAccountSignupGet renders the sign-up form.
024func (s *Server) handleAccountSignupGet(w http.ResponseWriter, r *http.Request) {
25 _, sess, err := s.getSessionRepoOrErr(r)
26 if err == nil {
···35 s.renderSignupForm(w, r, sess, "", "", "")
36}
3738+// handleAccountSignupPost creates an account from the sign-up form.
0039func (s *Server) handleAccountSignupPost(w http.ResponseWriter, r *http.Request) {
40 ctx := r.Context()
41 logger := s.logger.With("name", "handleAccountSignupPost")
···62 s.renderSignupForm(w, r, sess, handle, email, inviteCode)
63 }
640065 if handle == "" || email == "" || password == "" {
66 fail("All fields are required.")
67 return
68 }
6970+ // Add the server domain if needed.
071 if !strings.Contains(handle, ".") {
72 handle = handle + "." + s.config.Hostname
73 }
74 handle = strings.ToLower(handle)
7576+ // Validate the handle.
77 if _, err := syntax.ParseHandle(handle); err != nil {
78 fail("Invalid handle. Use only letters, numbers and hyphens.")
79 return
80 }
8182+ // Ensure the handle is on this server.
83 if !strings.HasSuffix(handle, "."+s.config.Hostname) && handle != s.config.Hostname {
84 fail("Handle must be under " + s.config.Hostname + ".")
85 return
···89 fail("Password must be at least 6 characters.")
90 return
91 }
009293 var ic models.InviteCode
94 if s.config.RequireInvite {
···113 }
114 }
11500116 actor, err := s.getActorByHandle(ctx, handle)
117 if err != nil && err != gorm.ErrRecordNotFound {
118 logger.Error("error looking up handle", "error", err)
···129 return
130 }
13100132 existingRepo, err := s.getRepoByEmail(ctx, email)
133 if err != nil && err != gorm.ErrRecordNotFound {
134 logger.Error("error looking up email", "error", err)
···139 fail("That email address is already registered.")
140 return
141 }
00142143 k, err := atcrypto.GeneratePrivateKeyK256()
144 if err != nil {
···160 return
161 }
16200163 hashed, err := bcrypt.GenerateFromPassword([]byte(password), 10)
164 if err != nil {
165 logger.Error("error hashing password", "error", err)
···192 return
193 }
19400195 bs := newBlockstoreForRepo(did, s.ipfsConfig)
196197 clk := syntax.NewTIDClock(0)
···226 logger.Error("failed to add identity event", "error", err)
227 }
22800229 if s.config.RequireInvite {
230 if err := s.db.Raw(ctx, "UPDATE invite_codes SET remaining_use_count = remaining_use_count - 1 WHERE code = ?", nil, inviteCode).Scan(&ic).Error; err != nil {
231 logger.Error("error decrementing invite code use count", "error", err)
···240 }
241 }
24200243 go func() {
244 if err := s.sendEmailVerification(email, handle, *urepo.EmailVerificationCode); err != nil {
245 logger.Error("error sending email verification", "error", err)
···248 logger.Error("error sending welcome email", "error", err)
249 }
250 }()
00251252 sess.Options = &sessions.Options{
253 Path: "/",
···271 }
272}
273274+// renderSignupForm renders `signup.html`.
00275func (s *Server) renderSignupForm(w http.ResponseWriter, r *http.Request, sess *sessions.Session, handle, email, inviteCode string) {
276 if err := s.renderTemplate(w, "signup.html", map[string]any{
277 "Hostname": s.config.Hostname,