Vow, uncensorable PDS written in Go

docs: simplify comments

+107 -186
+16 -29
blockstore/ipfs.go
··· 15 15 "github.com/ipfs/go-cid" 16 16 ) 17 17 18 - // 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. 18 + // IPFSBlockstore stores blocks through Kubo. 26 19 type IPFSBlockstore struct { 27 20 nodeURL string 28 21 did string ··· 33 26 inserts map[cid.Cid]blocks.Block 34 27 } 35 28 36 - // NewIPFS creates a new IPFSBlockstore that talks to the Kubo node at nodeURL. 29 + // NewIPFS creates a blockstore. 37 30 func NewIPFS(did string, nodeURL string, cli *http.Client) *IPFSBlockstore { 38 31 if nodeURL == "" { 39 32 nodeURL = "http://127.0.0.1:5001" ··· 49 42 } 50 43 } 51 44 52 - // 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. 45 + // SetRev stores the revision. 54 46 func (bs *IPFSBlockstore) SetRev(rev string) { 55 47 bs.rev = rev 56 48 } 57 49 58 - // Get retrieves a block by CID. It first checks the local write cache and 59 - // falls back to the IPFS node. 50 + // Get returns a block by CID. 60 51 func (bs *IPFSBlockstore) Get(ctx context.Context, c cid.Cid) (blocks.Block, error) { 61 52 bs.mu.RLock() 62 53 if blk, ok := bs.inserts[c]; ok { ··· 96 87 return blk, nil 97 88 } 98 89 99 - // Put writes a single block to the IPFS node and caches it locally. 90 + // Put stores one block. 100 91 func (bs *IPFSBlockstore) Put(ctx context.Context, block blocks.Block) error { 101 92 bs.mu.Lock() 102 93 bs.inserts[block.Cid()] = block ··· 109 100 return nil 110 101 } 111 102 112 - // PutMany writes multiple blocks to the IPFS node in sequence. 103 + // PutMany stores multiple blocks. 113 104 func (bs *IPFSBlockstore) PutMany(ctx context.Context, blks []blocks.Block) error { 114 105 for _, blk := range blks { 115 106 bs.mu.Lock() ··· 124 115 } 125 116 126 117 func (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. 118 + // Keep the same CID. 129 119 pref := blk.Cid().Prefix() 130 120 131 121 codecName, err := codecToName(pref.Codec) ··· 175 165 return fmt.Errorf("ipfs block/put returned %d: %s", resp.StatusCode, string(msg)) 176 166 } 177 167 178 - // Verify the CID returned by the node matches what we expect. 168 + // Verify the returned CID. 179 169 var result struct { 180 170 Key string `json:"Key"` 181 171 Size int `json:"Size"` ··· 196 186 return nil 197 187 } 198 188 199 - // Has checks the local cache first, then asks the IPFS node. 189 + // Has reports whether a block exists. 200 190 func (bs *IPFSBlockstore) Has(ctx context.Context, c cid.Cid) (bool, error) { 201 191 bs.mu.RLock() 202 192 if _, ok := bs.inserts[c]; ok { ··· 221 211 return resp.StatusCode == http.StatusOK, nil 222 212 } 223 213 224 - // GetSize returns the size of the block data. 214 + // GetSize returns the size. 225 215 func (bs *IPFSBlockstore) GetSize(ctx context.Context, c cid.Cid) (int, error) { 226 216 blk, err := bs.Get(ctx, c) 227 217 if err != nil { ··· 230 220 return len(blk.RawData()), nil 231 221 } 232 222 233 - // DeleteBlock is a no-op for IPFS; blocks are garbage-collected by the node. 223 + // DeleteBlock removes a block from the cache and unpins it. 234 224 func (bs *IPFSBlockstore) DeleteBlock(ctx context.Context, c cid.Cid) error { 235 225 bs.mu.Lock() 236 226 delete(bs.inserts, c) 237 227 bs.mu.Unlock() 238 228 239 - // Attempt to unpin; ignore errors since the block may not be pinned 240 - // individually (it could be part of a DAG pin). 229 + // Ignore unpin errors. 241 230 endpoint := fmt.Sprintf("%s/api/v0/pin/rm?arg=%s", bs.nodeURL, c.String()) 242 231 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) 243 232 if err != nil { ··· 253 242 return nil 254 243 } 255 244 256 - // AllKeysChan is not supported on the IPFS blockstore. 245 + // AllKeysChan is unsupported. 257 246 func (bs *IPFSBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) { 258 247 return nil, fmt.Errorf("iteration not supported on IPFS blockstore") 259 248 } ··· 261 250 // HashOnRead is a no-op. 262 251 func (bs *IPFSBlockstore) HashOnRead(bool) {} 263 252 264 - // GetWriteLog returns the blocks written during this session, matching the 265 - // interface used by RecordingBlockstore for firehose event construction. 253 + // GetWriteLog returns written blocks. 266 254 func (bs *IPFSBlockstore) GetWriteLog() map[cid.Cid]blocks.Block { 267 255 bs.mu.RLock() 268 256 defer bs.mu.RUnlock() ··· 272 260 return out 273 261 } 274 262 275 - // codecToName converts a CID codec number to the string name expected by the 276 - // Kubo /api/v0/block/put endpoint. 263 + // codecToName converts a CID codec to a Kubo name. 277 264 func codecToName(codec uint64) (string, error) { 278 265 switch codec { 279 266 case cid.DagCBOR: ··· 289 276 } 290 277 } 291 278 292 - // mhtypeToName converts a multihash type code to its string name. 279 + // mhtypeToName converts a multihash type to its name. 293 280 func mhtypeToName(mhtype uint64) (string, error) { 294 281 switch mhtype { 295 282 case 0x12: // sha2-256
+1 -2
blockstore/recording.go
··· 9 9 "github.com/ipfs/go-cid" 10 10 ) 11 11 12 - // RecordingBlockstore wraps a Blockstore and records all reads and writes 13 - // performed against it, for later inspection. 12 + // RecordingBlockstore records blockstore reads and writes. 14 13 type RecordingBlockstore struct { 15 14 base boxoblockstore.Blockstore 16 15
+21 -29
docker-compose.yaml
··· 5 5 dockerfile: Dockerfile 6 6 container_name: vow-init-keys 7 7 volumes: 8 - - ./keys:/keys 9 - - ./data:/data/vow 10 - - ./init-keys.sh:/init-keys.sh:ro 8 + - /opt/vowpds/keys:/keys 9 + - /opt/vowpds/data:/data/vow 10 + - /opt/vowpds/init-keys.sh:/init-keys.sh:ro 11 11 environment: 12 12 VOW_DID: ${VOW_DID} 13 13 VOW_HOSTNAME: ${VOW_HOSTNAME} ··· 25 25 volumes: 26 26 - ipfs_data:/data/ipfs 27 27 environment: 28 - # "server" profile disables mDNS/LAN discovery, appropriate for a 29 - # server deployment. 28 + # Disable local network discovery. 30 29 IPFS_PROFILE: server 31 30 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. 31 + # Expose the IPFS gateway to the reverse proxy only. 34 32 - "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. 33 + # Keep the RPC API internal. 37 34 restart: unless-stopped 38 35 healthcheck: 39 36 test: ["CMD", "ipfs", "id"] ··· 55 52 ports: 56 53 - "127.0.0.1:8080:8080" 57 54 volumes: 58 - - ./data:/data/vow 59 - - ./keys/rotation.key:/keys/rotation.key:ro 60 - - ./keys/jwk.key:/keys/jwk.key:ro 55 + - /opt/vowpds/data:/data/vow 56 + - /opt/vowpds/keys/rotation.key:/keys/rotation.key:ro 57 + - /opt/vowpds/keys/jwk.key:/keys/jwk.key:ro 61 58 environment: 62 - # ── Required ──────────────────────────────────────────────────────── 59 + # Required 63 60 VOW_DID: ${VOW_DID} 64 61 VOW_HOSTNAME: ${VOW_HOSTNAME} 65 62 VOW_ROTATION_KEY_PATH: /keys/rotation.key ··· 69 66 VOW_ADMIN_PASSWORD: ${VOW_ADMIN_PASSWORD} 70 67 VOW_SESSION_SECRET: ${VOW_SESSION_SECRET} 71 68 72 - # ── Server ────────────────────────────────────────────────────────── 69 + # Server 73 70 VOW_ADDR: ":8080" 74 71 VOW_DB_NAME: ${VOW_DB_NAME:-/data/vow/vow.db} 75 72 76 - # ── SMTP (optional) ───────────────────────────────────────────────── 73 + # SMTP (optional) 77 74 VOW_SMTP_USER: ${VOW_SMTP_USER:-} 78 75 VOW_SMTP_PASS: ${VOW_SMTP_PASS:-} 79 76 VOW_SMTP_HOST: ${VOW_SMTP_HOST:-} ··· 81 78 VOW_SMTP_EMAIL: ${VOW_SMTP_EMAIL:-} 82 79 VOW_SMTP_NAME: ${VOW_SMTP_NAME:-} 83 80 84 - # ── IPFS ──────────────────────────────────────────────────────────── 85 - # RPC API resolves to the ipfs service over the internal Docker network. 81 + # IPFS 82 + # Use the internal ipfs service for the RPC API. 86 83 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. 84 + # Optional public gateway for sync.getBlob redirects. 89 85 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. 86 + # Optional x402-gated remote pinning service. 94 87 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). 88 + # CAIP-2 chain ID for x402 pinning. 97 89 VOW_X402_NETWORK: ${VOW_X402_NETWORK:-eip155:8453} 98 90 99 - # ── Misc (optional) ───────────────────────────────────────────────── 91 + # Misc (optional) 100 92 VOW_FALLBACK_PROXY: ${VOW_FALLBACK_PROXY:-} 101 93 restart: unless-stopped 102 94 healthcheck: ··· 113 105 container_name: vow-create-invite 114 106 network_mode: "service:vow" 115 107 volumes: 116 - - ./keys:/keys 117 - - ./data:/data/vow 118 - - ./create-initial-invite.sh:/create-initial-invite.sh:ro 108 + - /opt/vowpds/keys:/keys 109 + - /opt/vowpds/data:/data/vow 110 + - /opt/vowpds/create-initial-invite.sh:/create-initial-invite.sh:ro 119 111 environment: 120 112 VOW_DID: ${VOW_DID} 121 113 VOW_HOSTNAME: ${VOW_HOSTNAME}
+1 -1
identity/passport.go
··· 8 8 9 9 type contextKey string 10 10 11 - // SkipCacheKey is the context key used to bypass the passport cache. 11 + // SkipCacheKey bypasses the passport cache. 12 12 const SkipCacheKey contextKey = "skip-cache" 13 13 14 14 type BackingCache interface {
+1 -2
internal/helpers/helpers.go
··· 12 12 "github.com/lestrrat-go/jwx/v2/jwk" 13 13 ) 14 14 15 - // 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}$/ 15 + // Invite code alphabet. 17 16 var letters = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") 18 17 19 18 func writeJSON(w http.ResponseWriter, status int, v any) {
+4 -15
models/models.go
··· 36 36 X402PinningEnabled bool `gorm:"default:false"` 37 37 } 38 38 39 - // 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. 39 + // EthereumAddress returns the Ethereum address for PublicKey. 47 40 func (r *Repo) EthereumAddress() string { 48 41 if len(r.PublicKey) == 0 { 49 42 return "" ··· 100 93 ID string `gorm:"primaryKey"` 101 94 Did string `gorm:"index"` 102 95 PayloadHash string 103 - // Payload is the canonical bytes that the client must sign. 96 + // Bytes to sign. 104 97 Payload []byte 105 - // Data holds the original serialised write request so it can be replayed 106 - // after signature verification. 98 + // Original request. 107 99 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. 100 + // Serialized commit state. 112 101 CommitData []byte 113 102 CreatedAt time.Time 114 103 ExpiresAt time.Time `gorm:"index"`
+2 -5
oauth/client/manager.go
··· 18 18 "pkg.rbrt.fr/vow/internal/helpers" 19 19 ) 20 20 21 - // supportedScopes lists the OAuth scopes this server accepts. 21 + // supportedScopes lists accepted OAuth scopes. 22 22 var supportedScopes = []string{"atproto", "transition:generic", "transition:chat.bsky"} 23 23 24 24 type Manager struct { ··· 165 165 return jwks, nil 166 166 } 167 167 168 - // 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. 168 + // selectKey picks a signing key from a JWKS list. 171 169 func selectKey(keys []any, kidHint string) (jwk.Key, error) { 172 170 if len(keys) == 0 { 173 171 return nil, errors.New("empty jwks") ··· 303 301 case "implicit": 304 302 return nil, errors.New("grant type `implicit` is not allowed") 305 303 case "authorization_code", "refresh_token": 306 - // supported 307 304 default: 308 305 return nil, fmt.Errorf("grant type `%s` is not supported", gt) 309 306 }
+3 -3
oauth/constants/constants.go
··· 44 44 45 45 DpopNonceMaxAge = 3 * time.Minute 46 46 47 - ConfidentialClientSessionLifetime = 2 * 365 * 24 * time.Hour // 2 years 48 - ConfidentialClientRefreshLifetime = 3 * 30 * 24 * time.Hour // 3 months 47 + ConfidentialClientSessionLifetime = 2 * 365 * 24 * time.Hour // 2y 48 + ConfidentialClientRefreshLifetime = 3 * 30 * 24 * time.Hour // 3mo 49 49 50 - PublicClientSessionLifetime = 2 * 7 * 24 * time.Hour // 2 weeks 50 + PublicClientSessionLifetime = 2 * 7 * 24 * time.Hour // 2w 51 51 PublicClientRefreshLifetime = PublicClientSessionLifetime 52 52 )
+2 -2
oauth/dpop/manager.go
··· 14 14 "time" 15 15 16 16 "github.com/golang-jwt/jwt/v4" 17 + "github.com/lestrrat-go/jwx/v2/jwa" 18 + "github.com/lestrrat-go/jwx/v2/jwk" 17 19 "pkg.rbrt.fr/vow/internal/helpers" 18 20 "pkg.rbrt.fr/vow/oauth/constants" 19 - "github.com/lestrrat-go/jwx/v2/jwa" 20 - "github.com/lestrrat-go/jwx/v2/jwk" 21 21 ) 22 22 23 23 type Manager struct {
+2 -4
plc/client.go
··· 89 89 return c.createDidCredentialsFromPublicKey(pubsigkey, recovery, handle) 90 90 } 91 91 92 - // 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. 92 + // CreateDidCredentialsFromPublicKey builds DID credentials from a public key. 95 93 func (c *Client) CreateDidCredentialsFromPublicKey(pubsigkey atcrypto.PublicKey, recovery string, handle string) (*DidCredentials, error) { 96 94 return c.createDidCredentialsFromPublicKey(pubsigkey, recovery, handle) 97 95 } ··· 102 100 return nil, err 103 101 } 104 102 105 - // todo 103 + // Put the recovery key first when present. 106 104 rotationKeys := []string{pubrotkey.DIDKey()} 107 105 if recovery != "" { 108 106 rotationKeys = func(recovery string) []string {
+39 -39
readme.md
··· 3 3 > [!WARNING] 4 4 > This is highly experimental software. Use with caution, especially during account migration. 5 5 6 - Vow is a PDS (Personal Data Server) implementation in Go for the AT Protocol. 6 + Vow is a Go PDS (Personal Data Server) for the AT Protocol. 7 7 8 8 ## Features 9 9 10 - - ✅ **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. 10 + - ✅ **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. 15 15 16 16 ## Quick Start with Docker Compose 17 17 ··· 58 58 ``` 59 59 60 60 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 61 + - **ipfs** — a Kubo node for repo blocks and blobs 62 + - **vow** — the PDS 63 63 - **create-invite** — creates an initial invite code on first run 64 64 65 65 5. **Get your invite code** ··· 83 83 84 84 ### What Gets Set Up 85 85 86 - - **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 86 + - **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 89 - **create-invite**: Creates an initial invite code on first run 90 90 91 91 ### Data Persistence 92 92 93 - - `./keys/` — Cryptographic keys (generated automatically) 93 + - `./keys/` — generated keys 94 94 - `rotation.key` — PDS rotation key 95 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 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 99 99 100 100 ### Reverse Proxy 101 101 102 - You will need a reverse proxy (nginx, Caddy, etc.) in front of both services: 102 + You need a reverse proxy (nginx, Caddy, etc.) in front of both services: 103 103 104 104 | Service | Internal address | Purpose | 105 105 | ------- | ---------------- | ----------------------------- | 106 106 | vow | `127.0.0.1:8080` | AT Protocol PDS | 107 107 | ipfs | `127.0.0.1:8081` | IPFS gateway for blob serving | 108 108 109 - 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. 109 + Set `VOW_IPFS_GATEWAY_URL` to your public gateway URL so `sync.getBlob` redirects clients there instead of proxying through vow. 110 110 111 111 ## Configuration 112 112 113 113 ### Database 114 114 115 - Vow uses SQLite for relational metadata (accounts, sessions, records index, tokens, etc.). No additional setup is required. 115 + Vow uses SQLite for relational metadata such as accounts, sessions, record indexes, and tokens. No extra setup is required. 116 116 117 117 ```bash 118 118 VOW_DB_NAME="/data/vow/vow.db" ··· 120 120 121 121 ### IPFS Node 122 122 123 - 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: 123 + 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: 124 124 125 125 ```bash 126 126 # URL of the Kubo RPC API ··· 131 131 VOW_IPFS_GATEWAY_URL="https://ipfs.example.com" 132 132 ``` 133 133 134 - `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. 134 + `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. 135 135 136 136 ### SMTP Email 137 137 ··· 146 146 147 147 ### BYOK (Bring Your Own Key) 148 148 149 - 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. 149 + 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. 150 150 151 151 #### End-to-end signing flow 152 152 153 - 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. 153 + 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. 154 154 155 155 ``` 156 156 ATProto client PDS (vow) Account page (browser) Ethereum wallet ··· 169 169 170 170 **What requires a wallet signature:** 171 171 172 - Only operations that modify the user's repo or identity require a round-trip to the wallet: 172 + Only operations that change the user's repo or identity need a wallet signature: 173 173 174 174 - **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites` 175 175 - **Identity operations** — PLC operations, handle updates 176 176 - **x402 payments** — EIP-712 payment authorisations for gated pinning 177 177 178 - 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. 178 + 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. 179 179 180 180 #### Service auth caching 181 181 182 - 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. 182 + 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. 183 183 184 - 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. 184 + 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. 185 185 186 186 Tokens requested explicitly via `com.atproto.server.getServiceAuth` (where the caller controls the expiry) bypass the cache and always go to the wallet. 187 187 188 188 #### WebSocket connection 189 189 190 - The account page connects using the session cookie (set at sign-in): 190 + The account page connects with the session cookie set at sign-in: 191 191 192 192 ``` 193 193 GET /account/signer 194 194 Cookie: <session-cookie> 195 195 ``` 196 196 197 - 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. 197 + 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. 198 198 199 199 A legacy Bearer-token endpoint is also available for programmatic clients: 200 200 ··· 239 239 240 240 ### Browser-Based Signer 241 241 242 - 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. 242 + 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. 243 243 244 244 ## Identity & DID Sovereignty 245 245 246 - 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**. 246 + 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**. 247 247 248 248 ### The problem with standard ATProto 249 249 250 - 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. 250 + 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. 251 251 252 252 ### Vow's approach: trust-then-transfer 253 253 254 254 Vow uses a two-phase model: 255 255 256 - **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. 256 + **Phase 1 — Account creation.** `createAccount` works like standard ATProto: the PDS creates the `did:plc` with its own rotation key. 257 257 258 - **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. 258 + **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. 259 259 260 - ### What the user gains 260 + ### What the user gets 261 261 262 262 | Property | Before key registration | After key registration | 263 263 | --------------------------- | -------------------------- | ------------------------------------------------- | ··· 269 269 270 270 ### Verifiability 271 271 272 - 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. 272 + 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`. 273 273 274 274 ### Why not `did:key` or on-chain? 275 275 276 - - **`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. 276 + - **`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. 279 279 280 - `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. 280 + `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. 281 281 282 282 ## Management Commands 283 283
+2 -5
server/blockstore_factory.go
··· 4 4 vowblockstore "pkg.rbrt.fr/vow/blockstore" 5 5 ) 6 6 7 - // 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. 7 + // newBlockstoreForRepo returns the blockstore for a DID. 9 8 func newBlockstoreForRepo(did string, ipfsCfg *IPFSConfig) *vowblockstore.IPFSBlockstore { 10 9 return vowblockstore.NewIPFS(did, ipfsCfg.NodeURL, nil) 11 10 } 12 11 13 - // 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. 12 + // newRecordingBlockstoreForRepo adds read/write logging. 16 13 func newRecordingBlockstoreForRepo(did string, ipfsCfg *IPFSConfig) (*vowblockstore.RecordingBlockstore, *vowblockstore.IPFSBlockstore) { 17 14 base := newBlockstoreForRepo(did, ipfsCfg) 18 15 return vowblockstore.NewRecording(base), base
+6 -17
server/handle_account_signer.go
··· 11 11 "github.com/gorilla/websocket" 12 12 ) 13 13 14 - // 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. 14 + // handleAccountSigner upgrades an account session to a signer WebSocket. 23 15 func (s *Server) handleAccountSigner(w http.ResponseWriter, r *http.Request) { 24 16 logger := s.logger.With("name", "handleAccountSigner") 25 17 26 - // Authenticate via the web session cookie. 18 + // Authenticate the session. 27 19 repo, _, err := s.getSessionRepoOrErr(r) 28 20 if err != nil { 29 21 http.Error(w, "Unauthorized", http.StatusUnauthorized) ··· 31 23 } 32 24 did := repo.Repo.Did 33 25 34 - // Ensure the account has a public key registered. 26 + // Require a public key. 35 27 if len(repo.PublicKey) == 0 { 36 28 http.Error(w, "no signing key registered for this account", http.StatusBadRequest) 37 29 return ··· 46 38 47 39 logger.Info("browser signer connected", "did", did) 48 40 49 - // Register this connection with the hub, evicting any previous connection 50 - // for the same DID (e.g. from another tab). 41 + // Replace any existing signer connection for this DID. 51 42 sc := s.signerHub.Register(did) 52 43 defer s.signerHub.Unregister(did, sc) 53 44 54 - // Configure read deadline + pong handler for keep-alive. 45 + // Keep the connection alive. 55 46 if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil { 56 47 logger.Error("signer: failed to set initial read deadline", "did", did, "error", err) 57 48 return ··· 63 54 pingTicker := time.NewTicker(20 * time.Second) 64 55 defer pingTicker.Stop() 65 56 66 - // 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. 57 + // Session validity was checked during upgrade. 69 58 70 59 readErr := make(chan error, 1) 71 60 inbound := make(chan wsIncoming, 4)
+1 -1
server/handle_account_signin.go
··· 101 101 queryParams = fmt.Sprintf("?%s", req.QueryParams) 102 102 } 103 103 104 - // TODO: we should make this a helper since we do it for the base create_session as well 104 + // TODO: extract this shared lookup into a helper. 105 105 var repo models.RepoActor 106 106 var err error 107 107 switch idtype {
+6 -32
server/handle_account_signup.go
··· 20 20 "pkg.rbrt.fr/vow/models" 21 21 ) 22 22 23 - // handleAccountSignupGet renders the sign-up form. If the user already has a 24 - // valid web session they are redirected to the account page instead. 23 + // handleAccountSignupGet renders the sign-up form. 25 24 func (s *Server) handleAccountSignupGet(w http.ResponseWriter, r *http.Request) { 26 25 _, sess, err := s.getSessionRepoOrErr(r) 27 26 if err == nil { ··· 36 35 s.renderSignupForm(w, r, sess, "", "", "") 37 36 } 38 37 39 - // 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. 38 + // handleAccountSignupPost creates an account from the sign-up form. 42 39 func (s *Server) handleAccountSignupPost(w http.ResponseWriter, r *http.Request) { 43 40 ctx := r.Context() 44 41 logger := s.logger.With("name", "handleAccountSignupPost") ··· 65 62 s.renderSignupForm(w, r, sess, handle, email, inviteCode) 66 63 } 67 64 68 - // ── Basic validation ───────────────────────────────────────────────── 69 - 70 65 if handle == "" || email == "" || password == "" { 71 66 fail("All fields are required.") 72 67 return 73 68 } 74 69 75 - // The handle entered in the form is the local part only (e.g. "alice"). 76 - // Append the server domain to produce the full handle. 70 + // Add the server domain if needed. 77 71 if !strings.Contains(handle, ".") { 78 72 handle = handle + "." + s.config.Hostname 79 73 } 80 74 handle = strings.ToLower(handle) 81 75 82 - // Validate handle syntax. 76 + // Validate the handle. 83 77 if _, err := syntax.ParseHandle(handle); err != nil { 84 78 fail("Invalid handle. Use only letters, numbers and hyphens.") 85 79 return 86 80 } 87 81 88 - // Validate that the handle's domain suffix matches the server hostname. 82 + // Ensure the handle is on this server. 89 83 if !strings.HasSuffix(handle, "."+s.config.Hostname) && handle != s.config.Hostname { 90 84 fail("Handle must be under " + s.config.Hostname + ".") 91 85 return ··· 95 89 fail("Password must be at least 6 characters.") 96 90 return 97 91 } 98 - 99 - // ── Invite code ────────────────────────────────────────────────────── 100 92 101 93 var ic models.InviteCode 102 94 if s.config.RequireInvite { ··· 121 113 } 122 114 } 123 115 124 - // ── Check handle availability ──────────────────────────────────────── 125 - 126 116 actor, err := s.getActorByHandle(ctx, handle) 127 117 if err != nil && err != gorm.ErrRecordNotFound { 128 118 logger.Error("error looking up handle", "error", err) ··· 139 129 return 140 130 } 141 131 142 - // ── Check email availability ───────────────────────────────────────── 143 - 144 132 existingRepo, err := s.getRepoByEmail(ctx, email) 145 133 if err != nil && err != gorm.ErrRecordNotFound { 146 134 logger.Error("error looking up email", "error", err) ··· 151 139 fail("That email address is already registered.") 152 140 return 153 141 } 154 - 155 - // ── Create DID ─────────────────────────────────────────────────────── 156 142 157 143 k, err := atcrypto.GeneratePrivateKeyK256() 158 144 if err != nil { ··· 174 160 return 175 161 } 176 162 177 - // ── Create repo + actor rows ───────────────────────────────────────── 178 - 179 163 hashed, err := bcrypt.GenerateFromPassword([]byte(password), 10) 180 164 if err != nil { 181 165 logger.Error("error hashing password", "error", err) ··· 208 192 return 209 193 } 210 194 211 - // ── Genesis commit ─────────────────────────────────────────────────── 212 - 213 195 bs := newBlockstoreForRepo(did, s.ipfsConfig) 214 196 215 197 clk := syntax.NewTIDClock(0) ··· 244 226 logger.Error("failed to add identity event", "error", err) 245 227 } 246 228 247 - // ── Consume invite code ────────────────────────────────────────────── 248 - 249 229 if s.config.RequireInvite { 250 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 { 251 231 logger.Error("error decrementing invite code use count", "error", err) ··· 260 240 } 261 241 } 262 242 263 - // ── Send emails ────────────────────────────────────────────────────── 264 - 265 243 go func() { 266 244 if err := s.sendEmailVerification(email, handle, *urepo.EmailVerificationCode); err != nil { 267 245 logger.Error("error sending email verification", "error", err) ··· 270 248 logger.Error("error sending welcome email", "error", err) 271 249 } 272 250 }() 273 - 274 - // ── Establish web session and redirect ─────────────────────────────── 275 251 276 252 sess.Options = &sessions.Options{ 277 253 Path: "/", ··· 295 271 } 296 272 } 297 273 298 - // 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. 274 + // renderSignupForm renders `signup.html`. 301 275 func (s *Server) renderSignupForm(w http.ResponseWriter, r *http.Request, sess *sessions.Session, handle, email, inviteCode string) { 302 276 if err := s.renderTemplate(w, "signup.html", map[string]any{ 303 277 "Hostname": s.config.Hostname,