···1515 "github.com/ipfs/go-cid"
1616)
17171818-// IPFSBlockstore stores and retrieves blocks via a Kubo (go-ipfs) node using
1919-// its HTTP RPC API. It implements the boxo blockstore.Blockstore interface so
2020-// it can be used as a drop-in replacement for the SQLite-backed store.
2121-//
2222-// Blocks are written to IPFS via /api/v0/block/put and read back via
2323-// /api/v0/block/get. A local in-memory cache of pending writes is kept so
2424-// that blocks are immediately readable within the same commit cycle before
2525-// the IPFS node has finished processing them.
1818+// IPFSBlockstore stores blocks through Kubo.
2619type IPFSBlockstore struct {
2720 nodeURL string
2821 did string
···3326 inserts map[cid.Cid]blocks.Block
3427}
35283636-// NewIPFS creates a new IPFSBlockstore that talks to the Kubo node at nodeURL.
2929+// NewIPFS creates a blockstore.
3730func NewIPFS(did string, nodeURL string, cli *http.Client) *IPFSBlockstore {
3831 if nodeURL == "" {
3932 nodeURL = "http://127.0.0.1:5001"
···4942 }
5043}
51445252-// SetRev sets the revision string. This satisfies the revSetter interface used
5353-// by commitRepo so that the blockstore is compatible with the repo commit flow.
4545+// SetRev stores the revision.
5446func (bs *IPFSBlockstore) SetRev(rev string) {
5547 bs.rev = rev
5648}
57495858-// Get retrieves a block by CID. It first checks the local write cache and
5959-// falls back to the IPFS node.
5050+// Get returns a block by CID.
6051func (bs *IPFSBlockstore) Get(ctx context.Context, c cid.Cid) (blocks.Block, error) {
6152 bs.mu.RLock()
6253 if blk, ok := bs.inserts[c]; ok {
···9687 return blk, nil
9788}
98899999-// Put writes a single block to the IPFS node and caches it locally.
9090+// Put stores one block.
10091func (bs *IPFSBlockstore) Put(ctx context.Context, block blocks.Block) error {
10192 bs.mu.Lock()
10293 bs.inserts[block.Cid()] = block
···109100 return nil
110101}
111102112112-// PutMany writes multiple blocks to the IPFS node in sequence.
103103+// PutMany stores multiple blocks.
113104func (bs *IPFSBlockstore) PutMany(ctx context.Context, blks []blocks.Block) error {
114105 for _, blk := range blks {
115106 bs.mu.Lock()
···124115}
125116126117func (bs *IPFSBlockstore) putToIPFS(ctx context.Context, blk blocks.Block) error {
127127- // Use /api/v0/block/put with the correct codec and hash so the IPFS node
128128- // stores the block under the exact same CID we computed locally.
118118+ // Keep the same CID.
129119 pref := blk.Cid().Prefix()
130120131121 codecName, err := codecToName(pref.Codec)
···175165 return fmt.Errorf("ipfs block/put returned %d: %s", resp.StatusCode, string(msg))
176166 }
177167178178- // Verify the CID returned by the node matches what we expect.
168168+ // Verify the returned CID.
179169 var result struct {
180170 Key string `json:"Key"`
181171 Size int `json:"Size"`
···196186 return nil
197187}
198188199199-// Has checks the local cache first, then asks the IPFS node.
189189+// Has reports whether a block exists.
200190func (bs *IPFSBlockstore) Has(ctx context.Context, c cid.Cid) (bool, error) {
201191 bs.mu.RLock()
202192 if _, ok := bs.inserts[c]; ok {
···221211 return resp.StatusCode == http.StatusOK, nil
222212}
223213224224-// GetSize returns the size of the block data.
214214+// GetSize returns the size.
225215func (bs *IPFSBlockstore) GetSize(ctx context.Context, c cid.Cid) (int, error) {
226216 blk, err := bs.Get(ctx, c)
227217 if err != nil {
···230220 return len(blk.RawData()), nil
231221}
232222233233-// DeleteBlock is a no-op for IPFS; blocks are garbage-collected by the node.
223223+// DeleteBlock removes a block from the cache and unpins it.
234224func (bs *IPFSBlockstore) DeleteBlock(ctx context.Context, c cid.Cid) error {
235225 bs.mu.Lock()
236226 delete(bs.inserts, c)
237227 bs.mu.Unlock()
238228239239- // Attempt to unpin; ignore errors since the block may not be pinned
240240- // individually (it could be part of a DAG pin).
229229+ // Ignore unpin errors.
241230 endpoint := fmt.Sprintf("%s/api/v0/pin/rm?arg=%s", bs.nodeURL, c.String())
242231 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
243232 if err != nil {
···253242 return nil
254243}
255244256256-// AllKeysChan is not supported on the IPFS blockstore.
245245+// AllKeysChan is unsupported.
257246func (bs *IPFSBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) {
258247 return nil, fmt.Errorf("iteration not supported on IPFS blockstore")
259248}
···261250// HashOnRead is a no-op.
262251func (bs *IPFSBlockstore) HashOnRead(bool) {}
263252264264-// GetWriteLog returns the blocks written during this session, matching the
265265-// interface used by RecordingBlockstore for firehose event construction.
253253+// GetWriteLog returns written blocks.
266254func (bs *IPFSBlockstore) GetWriteLog() map[cid.Cid]blocks.Block {
267255 bs.mu.RLock()
268256 defer bs.mu.RUnlock()
···272260 return out
273261}
274262275275-// codecToName converts a CID codec number to the string name expected by the
276276-// Kubo /api/v0/block/put endpoint.
263263+// codecToName converts a CID codec to a Kubo name.
277264func codecToName(codec uint64) (string, error) {
278265 switch codec {
279266 case cid.DagCBOR:
···289276 }
290277}
291278292292-// mhtypeToName converts a multihash type code to its string name.
279279+// mhtypeToName converts a multihash type to its name.
293280func mhtypeToName(mhtype uint64) (string, error) {
294281 switch mhtype {
295282 case 0x12: // sha2-256
+1-2
blockstore/recording.go
···99 "github.com/ipfs/go-cid"
1010)
11111212-// RecordingBlockstore wraps a Blockstore and records all reads and writes
1313-// performed against it, for later inspection.
1212+// RecordingBlockstore records blockstore reads and writes.
1413type RecordingBlockstore struct {
1514 base boxoblockstore.Blockstore
1615
+21-29
docker-compose.yaml
···55 dockerfile: Dockerfile
66 container_name: vow-init-keys
77 volumes:
88- - ./keys:/keys
99- - ./data:/data/vow
1010- - ./init-keys.sh:/init-keys.sh:ro
88+ - /opt/vowpds/keys:/keys
99+ - /opt/vowpds/data:/data/vow
1010+ - /opt/vowpds/init-keys.sh:/init-keys.sh:ro
1111 environment:
1212 VOW_DID: ${VOW_DID}
1313 VOW_HOSTNAME: ${VOW_HOSTNAME}
···2525 volumes:
2626 - ipfs_data:/data/ipfs
2727 environment:
2828- # "server" profile disables mDNS/LAN discovery, appropriate for a
2929- # server deployment.
2828+ # Disable local network discovery.
3029 IPFS_PROFILE: server
3130 ports:
3232- # Expose the IPFS gateway so a reverse proxy can serve blobs over HTTPS.
3333- # Bound to 127.0.0.1 — not reachable from the public internet directly.
3131+ # Expose the IPFS gateway to the reverse proxy only.
3432 - "127.0.0.1:8081:8080"
3535- # The RPC API (port 5001) is intentionally NOT exposed to the host.
3636- # Only the vow container reaches it over the internal Docker network.
3333+ # Keep the RPC API internal.
3734 restart: unless-stopped
3835 healthcheck:
3936 test: ["CMD", "ipfs", "id"]
···5552 ports:
5653 - "127.0.0.1:8080:8080"
5754 volumes:
5858- - ./data:/data/vow
5959- - ./keys/rotation.key:/keys/rotation.key:ro
6060- - ./keys/jwk.key:/keys/jwk.key:ro
5555+ - /opt/vowpds/data:/data/vow
5656+ - /opt/vowpds/keys/rotation.key:/keys/rotation.key:ro
5757+ - /opt/vowpds/keys/jwk.key:/keys/jwk.key:ro
6158 environment:
6262- # ── Required ────────────────────────────────────────────────────────
5959+ # Required
6360 VOW_DID: ${VOW_DID}
6461 VOW_HOSTNAME: ${VOW_HOSTNAME}
6562 VOW_ROTATION_KEY_PATH: /keys/rotation.key
···6966 VOW_ADMIN_PASSWORD: ${VOW_ADMIN_PASSWORD}
7067 VOW_SESSION_SECRET: ${VOW_SESSION_SECRET}
71687272- # ── Server ──────────────────────────────────────────────────────────
6969+ # Server
7370 VOW_ADDR: ":8080"
7471 VOW_DB_NAME: ${VOW_DB_NAME:-/data/vow/vow.db}
75727676- # ── SMTP (optional) ─────────────────────────────────────────────────
7373+ # SMTP (optional)
7774 VOW_SMTP_USER: ${VOW_SMTP_USER:-}
7875 VOW_SMTP_PASS: ${VOW_SMTP_PASS:-}
7976 VOW_SMTP_HOST: ${VOW_SMTP_HOST:-}
···8178 VOW_SMTP_EMAIL: ${VOW_SMTP_EMAIL:-}
8279 VOW_SMTP_NAME: ${VOW_SMTP_NAME:-}
83808484- # ── IPFS ────────────────────────────────────────────────────────────
8585- # RPC API resolves to the ipfs service over the internal Docker network.
8181+ # IPFS
8282+ # Use the internal ipfs service for the RPC API.
8683 VOW_IPFS_NODE_URL: ${VOW_IPFS_NODE_URL:-http://ipfs:5001}
8787- # Public gateway URL for sync.getBlob redirects. Leave empty to have
8888- # vow proxy blob content itself.
8484+ # Optional public gateway for sync.getBlob redirects.
8985 VOW_IPFS_GATEWAY_URL: ${VOW_IPFS_GATEWAY_URL:-}
9090- # Optional x402-gated remote pinning. When set, accounts with x402
9191- # pinning enabled will have blobs additionally pinned here after local
9292- # storage, with payment signed by the user's Ethereum wallet via the
9393- # browser-based signer.
8686+ # Optional x402-gated remote pinning service.
9487 VOW_X402_PIN_URL: ${VOW_X402_PIN_URL:-}
9595- # CAIP-2 chain identifier for the x402 pinning service.
9696- # Defaults to Base Mainnet (eip155:8453).
8888+ # CAIP-2 chain ID for x402 pinning.
9789 VOW_X402_NETWORK: ${VOW_X402_NETWORK:-eip155:8453}
98909999- # ── Misc (optional) ─────────────────────────────────────────────────
9191+ # Misc (optional)
10092 VOW_FALLBACK_PROXY: ${VOW_FALLBACK_PROXY:-}
10193 restart: unless-stopped
10294 healthcheck:
···113105 container_name: vow-create-invite
114106 network_mode: "service:vow"
115107 volumes:
116116- - ./keys:/keys
117117- - ./data:/data/vow
118118- - ./create-initial-invite.sh:/create-initial-invite.sh:ro
108108+ - /opt/vowpds/keys:/keys
109109+ - /opt/vowpds/data:/data/vow
110110+ - /opt/vowpds/create-initial-invite.sh:/create-initial-invite.sh:ro
119111 environment:
120112 VOW_DID: ${VOW_DID}
121113 VOW_HOSTNAME: ${VOW_HOSTNAME}
+1-1
identity/passport.go
···8899type contextKey string
10101111-// SkipCacheKey is the context key used to bypass the passport cache.
1111+// SkipCacheKey bypasses the passport cache.
1212const SkipCacheKey contextKey = "skip-cache"
13131414type BackingCache interface {
+1-2
internal/helpers/helpers.go
···1212 "github.com/lestrrat-go/jwx/v2/jwk"
1313)
14141515-// This will confirm to the regex in the application if 5 chars are used for each side of the -
1616-// /^[A-Z2-7]{5}-[A-Z2-7]{5}$/
1515+// Invite code alphabet.
1716var letters = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
18171918func writeJSON(w http.ResponseWriter, status int, v any) {
+4-15
models/models.go
···3636 X402PinningEnabled bool `gorm:"default:false"`
3737}
38383939-// EthereumAddress derives the EIP-55 checksummed Ethereum address from the
4040-// stored compressed secp256k1 public key. Returns an empty string if no
4141-// public key has been registered yet.
4242-//
4343-// The derivation is: decompress pubkey → keccak256(pubkey[1:]) → take last 20 bytes.
4444-// This is the same address the user's Ethereum wallet (Rabby, MetaMask, etc.)
4545-// will present, so it can be passed as the "from" field in EIP-3009 payment
4646-// authorisations without any separate storage.
3939+// EthereumAddress returns the Ethereum address for PublicKey.
4740func (r *Repo) EthereumAddress() string {
4841 if len(r.PublicKey) == 0 {
4942 return ""
···10093 ID string `gorm:"primaryKey"`
10194 Did string `gorm:"index"`
10295 PayloadHash string
103103- // Payload is the canonical bytes that the client must sign.
9696+ // Bytes to sign.
10497 Payload []byte
105105- // Data holds the original serialised write request so it can be replayed
106106- // after signature verification.
9898+ // Original request.
10799 Data []byte
108108- // CommitData holds a JSON-serialised pendingCommitState that contains
109109- // everything needed to finalise the commit once the signature arrives:
110110- // the unsigned commit CBOR, MST diff blocks, record entries, firehose ops,
111111- // and per-op results. It is nil for PLC-operation pending writes.
100100+ // Serialized commit state.
112101 CommitData []byte
113102 CreatedAt time.Time
114103 ExpiresAt time.Time `gorm:"index"`
+2-5
oauth/client/manager.go
···1818 "pkg.rbrt.fr/vow/internal/helpers"
1919)
20202121-// supportedScopes lists the OAuth scopes this server accepts.
2121+// supportedScopes lists accepted OAuth scopes.
2222var supportedScopes = []string{"atproto", "transition:generic", "transition:chat.bsky"}
23232424type Manager struct {
···165165 return jwks, nil
166166}
167167168168-// selectKey picks the best signing key from a raw JWKS key list.
169169-// It prefers a key whose "kid" matches the hint (if non-empty), then any key
170170-// with "use"="sig", and finally falls back to the first key in the set.
168168+// selectKey picks a signing key from a JWKS list.
171169func selectKey(keys []any, kidHint string) (jwk.Key, error) {
172170 if len(keys) == 0 {
173171 return nil, errors.New("empty jwks")
···303301 case "implicit":
304302 return nil, errors.New("grant type `implicit` is not allowed")
305303 case "authorization_code", "refresh_token":
306306- // supported
307304 default:
308305 return nil, fmt.Errorf("grant type `%s` is not supported", gt)
309306 }
···8989 return c.createDidCredentialsFromPublicKey(pubsigkey, recovery, handle)
9090}
91919292-// CreateDidCredentialsFromPublicKey builds a DidCredentials struct from an
9393-// already-parsed public key. This is used on the BYOK path where the PDS only
9494-// holds the public key and must never touch a private key.
9292+// CreateDidCredentialsFromPublicKey builds DID credentials from a public key.
9593func (c *Client) CreateDidCredentialsFromPublicKey(pubsigkey atcrypto.PublicKey, recovery string, handle string) (*DidCredentials, error) {
9694 return c.createDidCredentialsFromPublicKey(pubsigkey, recovery, handle)
9795}
···102100 return nil, err
103101 }
104102105105- // todo
103103+ // Put the recovery key first when present.
106104 rotationKeys := []string{pubrotkey.DIDKey()}
107105 if recovery != "" {
108106 rotationKeys = func(recovery string) []string {
+39-39
readme.md
···33> [!WARNING]
44> This is highly experimental software. Use with caution, especially during account migration.
5566-Vow is a PDS (Personal Data Server) implementation in Go for the AT Protocol.
66+Vow is a Go PDS (Personal Data Server) for the AT Protocol.
7788## Features
991010-- ✅ **IPFS storage** — all repo blocks and blobs stored on a co-located Kubo node, indexed in SQLite by DID and CID.
1111-- ✅ **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.).
1212-- ✅ **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.
1313-- ✅ **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.
1414-- 🔜 **x402 payments for IPFS storage** — blob uploads gated behind on-chain payments via the [x402 protocol](https://x402.org), using the same Ethereum wallet.
1010+- ✅ **IPFS storage** — repo blocks and blobs are stored on a local Kubo node and indexed in SQLite by DID and CID.
1111+- ✅ **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.).
1212+- ✅ **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.
1313+- ✅ **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.
1414+- 🔜 **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.
15151616## Quick Start with Docker Compose
1717···5858 ```
59596060 This starts three services:
6161- - **ipfs** — a Kubo node storing all repo blocks and blobs
6262- - **vow** — the PDS, wired to the Kubo node automatically
6161+ - **ipfs** — a Kubo node for repo blocks and blobs
6262+ - **vow** — the PDS
6363 - **create-invite** — creates an initial invite code on first run
646465655. **Get your invite code**
···83838484### What Gets Set Up
85858686-- **init-keys**: Generates cryptographic keys (rotation key and JWK) on first run
8787-- **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.
8888-- **vow**: The main PDS service running on port 8080
8686+- **init-keys**: Generates the rotation key and JWK on first run
8787+- **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.
8888+- **vow**: The main PDS service on port 8080
8989- **create-invite**: Creates an initial invite code on first run
90909191### Data Persistence
92929393-- `./keys/` — Cryptographic keys (generated automatically)
9393+- `./keys/` — generated keys
9494 - `rotation.key` — PDS rotation key
9595 - `jwk.key` — JWK private key
9696- - `initial-invite-code.txt` — Your first invite code (first run only)
9797-- `./data/` — SQLite database (metadata only)
9898-- `ipfs_data` Docker volume — all IPFS blocks and blobs
9696+ - `initial-invite-code.txt` — first invite code (first run only)
9797+- `./data/` — SQLite metadata database
9898+- `ipfs_data` Docker volume — IPFS blocks and blobs
9999100100### Reverse Proxy
101101102102-You will need a reverse proxy (nginx, Caddy, etc.) in front of both services:
102102+You need a reverse proxy (nginx, Caddy, etc.) in front of both services:
103103104104| Service | Internal address | Purpose |
105105| ------- | ---------------- | ----------------------------- |
106106| vow | `127.0.0.1:8080` | AT Protocol PDS |
107107| ipfs | `127.0.0.1:8081` | IPFS gateway for blob serving |
108108109109-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.
109109+Set `VOW_IPFS_GATEWAY_URL` to your public gateway URL so `sync.getBlob` redirects clients there instead of proxying through vow.
110110111111## Configuration
112112113113### Database
114114115115-Vow uses SQLite for relational metadata (accounts, sessions, records index, tokens, etc.). No additional setup is required.
115115+Vow uses SQLite for relational metadata such as accounts, sessions, record indexes, and tokens. No extra setup is required.
116116117117```bash
118118VOW_DB_NAME="/data/vow/vow.db"
···120120121121### IPFS Node
122122123123-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:
123123+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:
124124125125```bash
126126# URL of the Kubo RPC API
···131131VOW_IPFS_GATEWAY_URL="https://ipfs.example.com"
132132```
133133134134-`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.
134134+`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.
135135136136### SMTP Email
137137···146146147147### BYOK (Bring Your Own Key)
148148149149-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.
149149+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.
150150151151#### End-to-end signing flow
152152153153-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.
153153+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.
154154155155```
156156ATProto client PDS (vow) Account page (browser) Ethereum wallet
···169169170170**What requires a wallet signature:**
171171172172-Only operations that modify the user's repo or identity require a round-trip to the wallet:
172172+Only operations that change the user's repo or identity need a wallet signature:
173173174174- **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites`
175175- **Identity operations** — PLC operations, handle updates
176176- **x402 payments** — EIP-712 payment authorisations for gated pinning
177177178178-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.
178178+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.
179179180180#### Service auth caching
181181182182-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.
182182+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.
183183184184-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.
184184+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.
185185186186Tokens requested explicitly via `com.atproto.server.getServiceAuth` (where the caller controls the expiry) bypass the cache and always go to the wallet.
187187188188#### WebSocket connection
189189190190-The account page connects using the session cookie (set at sign-in):
190190+The account page connects with the session cookie set at sign-in:
191191192192```
193193GET /account/signer
194194Cookie: <session-cookie>
195195```
196196197197-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.
197197+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.
198198199199A legacy Bearer-token endpoint is also available for programmatic clients:
200200···239239240240### Browser-Based Signer
241241242242-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.
242242+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.
243243244244## Identity & DID Sovereignty
245245246246-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**.
246246+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**.
247247248248### The problem with standard ATProto
249249250250-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.
250250+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.
251251252252### Vow's approach: trust-then-transfer
253253254254Vow uses a two-phase model:
255255256256-**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.
256256+**Phase 1 — Account creation.** `createAccount` works like standard ATProto: the PDS creates the `did:plc` with its own rotation key.
257257258258-**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.
258258+**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.
259259260260-### What the user gains
260260+### What the user gets
261261262262| Property | Before key registration | After key registration |
263263| --------------------------- | -------------------------- | ------------------------------------------------- |
···269269270270### Verifiability
271271272272-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.
272272+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`.
273273274274### Why not `did:key` or on-chain?
275275276276-- **`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.
277277-- **On-chain registry** — fully trustless, but forces every PDS implementation to integrate with a blockchain.
278278-- **`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.
276276+- **`did:key`** — elegant, but current ATProto AppViews and relays do not resolve it.
277277+- **On-chain registry** — fully trustless, but every PDS would need blockchain support.
278278+- **`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.
279279280280-`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.
280280+`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.
281281282282## Management Commands
283283
+2-5
server/blockstore_factory.go
···44 vowblockstore "pkg.rbrt.fr/vow/blockstore"
55)
6677-// newBlockstoreForRepo returns an IPFS-backed blockstore for the given DID.
88-// All repo blocks are stored on and retrieved from the co-located Kubo node.
77+// newBlockstoreForRepo returns the blockstore for a DID.
98func newBlockstoreForRepo(did string, ipfsCfg *IPFSConfig) *vowblockstore.IPFSBlockstore {
109 return vowblockstore.NewIPFS(did, ipfsCfg.NodeURL, nil)
1110}
12111313-// newRecordingBlockstoreForRepo wraps the IPFS blockstore in a
1414-// RecordingBlockstore so that all reads and writes during a commit are tracked
1515-// for firehose CAR slice construction.
1212+// newRecordingBlockstoreForRepo adds read/write logging.
1613func newRecordingBlockstoreForRepo(did string, ipfsCfg *IPFSConfig) (*vowblockstore.RecordingBlockstore, *vowblockstore.IPFSBlockstore) {
1714 base := newBlockstoreForRepo(did, ipfsCfg)
1815 return vowblockstore.NewRecording(base), base
+6-17
server/handle_account_signer.go
···1111 "github.com/gorilla/websocket"
1212)
13131414-// handleAccountSigner is the cookie-authenticated equivalent of handleSignerConnect.
1515-// It upgrades the connection to a WebSocket using the web session cookie for
1616-// authentication (set by handleAccountSigninPost) instead of requiring a Bearer
1717-// token. This allows the account page to act as the signer directly — no
1818-// browser extension needed.
1919-//
2020-// The WebSocket protocol is identical to handleSignerConnect: the PDS pushes
2121-// sign_request / pay_request frames and expects sign_response / sign_reject /
2222-// pay_response / pay_reject back.
1414+// handleAccountSigner upgrades an account session to a signer WebSocket.
2315func (s *Server) handleAccountSigner(w http.ResponseWriter, r *http.Request) {
2416 logger := s.logger.With("name", "handleAccountSigner")
25172626- // Authenticate via the web session cookie.
1818+ // Authenticate the session.
2719 repo, _, err := s.getSessionRepoOrErr(r)
2820 if err != nil {
2921 http.Error(w, "Unauthorized", http.StatusUnauthorized)
···3123 }
3224 did := repo.Repo.Did
33253434- // Ensure the account has a public key registered.
2626+ // Require a public key.
3527 if len(repo.PublicKey) == 0 {
3628 http.Error(w, "no signing key registered for this account", http.StatusBadRequest)
3729 return
···46384739 logger.Info("browser signer connected", "did", did)
48404949- // Register this connection with the hub, evicting any previous connection
5050- // for the same DID (e.g. from another tab).
4141+ // Replace any existing signer connection for this DID.
5142 sc := s.signerHub.Register(did)
5243 defer s.signerHub.Unregister(did, sc)
53445454- // Configure read deadline + pong handler for keep-alive.
4545+ // Keep the connection alive.
5546 if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
5647 logger.Error("signer: failed to set initial read deadline", "did", did, "error", err)
5748 return
···6354 pingTicker := time.NewTicker(20 * time.Second)
6455 defer pingTicker.Stop()
65566666- // No token expiry check needed — cookie sessions are long-lived and
6767- // validated on each HTTP request. The WebSocket stays open as long as the
6868- // browser tab is open.
5757+ // Session validity was checked during upgrade.
69587059 readErr := make(chan error, 1)
7160 inbound := make(chan wsIncoming, 4)
+1-1
server/handle_account_signin.go
···101101 queryParams = fmt.Sprintf("?%s", req.QueryParams)
102102 }
103103104104- // TODO: we should make this a helper since we do it for the base create_session as well
104104+ // TODO: extract this shared lookup into a helper.
105105 var repo models.RepoActor
106106 var err error
107107 switch idtype {
+6-32
server/handle_account_signup.go
···2020 "pkg.rbrt.fr/vow/models"
2121)
22222323-// handleAccountSignupGet renders the sign-up form. If the user already has a
2424-// valid web session they are redirected to the account page instead.
2323+// handleAccountSignupGet renders the sign-up form.
2524func (s *Server) handleAccountSignupGet(w http.ResponseWriter, r *http.Request) {
2625 _, sess, err := s.getSessionRepoOrErr(r)
2726 if err == nil {
···3635 s.renderSignupForm(w, r, sess, "", "", "")
3736}
38373939-// handleAccountSignupPost validates the form input, creates the account (DID,
4040-// repo, actor), establishes a web session and redirects to /account so the
4141-// user can register their signing key and connect the signer.
3838+// handleAccountSignupPost creates an account from the sign-up form.
4239func (s *Server) handleAccountSignupPost(w http.ResponseWriter, r *http.Request) {
4340 ctx := r.Context()
4441 logger := s.logger.With("name", "handleAccountSignupPost")
···6562 s.renderSignupForm(w, r, sess, handle, email, inviteCode)
6663 }
67646868- // ── Basic validation ─────────────────────────────────────────────────
6969-7065 if handle == "" || email == "" || password == "" {
7166 fail("All fields are required.")
7267 return
7368 }
74697575- // The handle entered in the form is the local part only (e.g. "alice").
7676- // Append the server domain to produce the full handle.
7070+ // Add the server domain if needed.
7771 if !strings.Contains(handle, ".") {
7872 handle = handle + "." + s.config.Hostname
7973 }
8074 handle = strings.ToLower(handle)
81758282- // Validate handle syntax.
7676+ // Validate the handle.
8377 if _, err := syntax.ParseHandle(handle); err != nil {
8478 fail("Invalid handle. Use only letters, numbers and hyphens.")
8579 return
8680 }
87818888- // Validate that the handle's domain suffix matches the server hostname.
8282+ // Ensure the handle is on this server.
8983 if !strings.HasSuffix(handle, "."+s.config.Hostname) && handle != s.config.Hostname {
9084 fail("Handle must be under " + s.config.Hostname + ".")
9185 return
···9589 fail("Password must be at least 6 characters.")
9690 return
9791 }
9898-9999- // ── Invite code ──────────────────────────────────────────────────────
1009210193 var ic models.InviteCode
10294 if s.config.RequireInvite {
···121113 }
122114 }
123115124124- // ── Check handle availability ────────────────────────────────────────
125125-126116 actor, err := s.getActorByHandle(ctx, handle)
127117 if err != nil && err != gorm.ErrRecordNotFound {
128118 logger.Error("error looking up handle", "error", err)
···139129 return
140130 }
141131142142- // ── Check email availability ─────────────────────────────────────────
143143-144132 existingRepo, err := s.getRepoByEmail(ctx, email)
145133 if err != nil && err != gorm.ErrRecordNotFound {
146134 logger.Error("error looking up email", "error", err)
···151139 fail("That email address is already registered.")
152140 return
153141 }
154154-155155- // ── Create DID ───────────────────────────────────────────────────────
156142157143 k, err := atcrypto.GeneratePrivateKeyK256()
158144 if err != nil {
···174160 return
175161 }
176162177177- // ── Create repo + actor rows ─────────────────────────────────────────
178178-179163 hashed, err := bcrypt.GenerateFromPassword([]byte(password), 10)
180164 if err != nil {
181165 logger.Error("error hashing password", "error", err)
···208192 return
209193 }
210194211211- // ── Genesis commit ───────────────────────────────────────────────────
212212-213195 bs := newBlockstoreForRepo(did, s.ipfsConfig)
214196215197 clk := syntax.NewTIDClock(0)
···244226 logger.Error("failed to add identity event", "error", err)
245227 }
246228247247- // ── Consume invite code ──────────────────────────────────────────────
248248-249229 if s.config.RequireInvite {
250230 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 {
251231 logger.Error("error decrementing invite code use count", "error", err)
···260240 }
261241 }
262242263263- // ── Send emails ──────────────────────────────────────────────────────
264264-265243 go func() {
266244 if err := s.sendEmailVerification(email, handle, *urepo.EmailVerificationCode); err != nil {
267245 logger.Error("error sending email verification", "error", err)
···270248 logger.Error("error sending welcome email", "error", err)
271249 }
272250 }()
273273-274274- // ── Establish web session and redirect ───────────────────────────────
275251276252 sess.Options = &sessions.Options{
277253 Path: "/",
···295271 }
296272}
297273298298-// renderSignupForm renders the signup.html template with the current form
299299-// state and any flash messages. It is used by both the GET handler and the
300300-// POST handler (on validation failure) to avoid duplicating template data.
274274+// renderSignupForm renders `signup.html`.
301275func (s *Server) renderSignupForm(w http.ResponseWriter, r *http.Request, sess *sessions.Session, handle, email, inviteCode string) {
302276 if err := s.renderTemplate(w, "signup.html", map[string]any{
303277 "Hostname": s.config.Hostname,