Vow, uncensorable PDS written in Go

docs: simplify comments

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