An in browser local PDS localpds.at
20

Configure Feed

Select the types of activity you want to include in your feed.

Add the writable routes

authored by

Niall Bunting and committed by
Niall Bunting
(Jun 3, 2026, 3:26 AM +0100) b22b15ae 1765b6c1

+59 -7
+6 -1
backend/firehose.go
··· 109 109 method := strings.TrimPrefix(c.FullPath(), "/xrpc/") 110 110 111 111 if did == "" { 112 + log.Println("ERROR: Missing did") 112 113 c.JSON(http.StatusBadRequest, gin.H{"error": "MissingParam", "message": "did required"}) 113 114 return 114 115 } 115 116 116 117 peer, ok := getPeer(did) 117 118 if !ok { 119 + log.Println("ERROR: No live peer") 118 120 c.JSON(http.StatusNotFound, gin.H{"error": "RepoNotFound", "message": "no live peer for " + did}) 119 121 return 120 122 } ··· 131 133 var err error 132 134 reqBody, err = io.ReadAll(c.Request.Body) 133 135 if err != nil { 136 + log.Println("ERROR: Failed to read body") 134 137 c.JSON(http.StatusInternalServerError, gin.H{"error": "InternalServerError", "message": "failed to read body"}) 135 138 return 136 139 } 137 140 defer c.Request.Body.Close() 138 141 } 139 142 140 - resp, err := requestFromPeer(c.Request.Context(), peer, method, params, reqBody) 143 + auth := c.GetHeader("Authorization") 144 + 145 + resp, err := requestFromPeer(c.Request.Context(), peer, method, params, reqBody, auth) 141 146 if err != nil { 142 147 log.Printf("peer request failed did=%s method=%s: %v", did, method, err) 143 148 c.JSON(http.StatusBadGateway, gin.H{"error": "BadGateway", "message": err.Error()})
+35 -5
backend/main.go
··· 27 27 r.GET("/xrpc/com.atproto.sync.listBlobs", getDidThenHandleSyncXRPC) 28 28 r.POST("/xrpc/com.atproto.sync.notifyOfUpdate", func(c *gin.Context) { c.Status(200) }) 29 29 30 - r.GET("/xrpc/com.atproto.repo.describeRepo", getDidThenHandleSyncXRPC) 31 - r.GET("/xrpc/com.atproto.repo.getRecord", getDidThenHandleSyncXRPC) 32 - r.GET("/xrpc/com.atproto.repo.listRecords", getDidThenHandleSyncXRPC) 33 - r.GET("/xrpc/com.atproto.repo.listMissingBlobs",getDidThenHandleSyncXRPC) 34 - 35 30 r.GET("/xrpc/com.atproto.sync.getBlob", getDidThenHandleSyncXRPC) 36 31 r.GET("/xrpc/com.atproto.sync.getBlocks", getDidThenHandleSyncXRPC) 37 32 r.GET("/xrpc/com.atproto.sync.getRecord", getDidThenHandleSyncXRPC) 38 33 r.GET("/xrpc/com.atproto.sync.getRepoStatus", getDidThenHandleSyncXRPC) 39 34 r.GET("/xrpc/com.atproto.sync.listRepos", getDidThenHandleSyncXRPC) 40 35 r.POST("/xrpc/com.atproto.sync.requestCrawl", getDidThenHandleSyncXRPC) 36 + 37 + // Repo routes 38 + r.GET("/xrpc/com.atproto.repo.describeRepo", getDidThenHandleSyncXRPC) 39 + r.GET("/xrpc/com.atproto.repo.getRecord", getDidThenHandleSyncXRPC) 40 + r.GET("/xrpc/com.atproto.repo.listRecords", getDidThenHandleSyncXRPC) 41 + r.GET("/xrpc/com.atproto.repo.listMissingBlobs",getDidThenHandleSyncXRPC) 42 + 43 + // Protected routes 44 + r.POST("/xrpc/com.atproto.repo.applyWrites", getDidThenHandleSyncXRPC) 45 + r.POST("/xrpc/com.atproto.repo.createRecord", getDidThenHandleSyncXRPC) 46 + r.POST("/xrpc/com.atproto.repo.putRecord", getDidThenHandleSyncXRPC) 47 + r.POST("/xrpc/com.atproto.repo.deleteRecord",getDidThenHandleSyncXRPC) 48 + //r.POST("/xrpc/com.atproto.repo.uploadBlob",getDidThenHandleSyncXRPC) 49 + 41 50 42 51 43 52 // TODO validate sig & move some of this out of here ··· 182 191 return 183 192 } 184 193 194 + if c.Request.Method == "POST" && c.Request.Body != nil { 195 + // 1. Read the body into a buffer 196 + bodyBytes, err := io.ReadAll(c.Request.Body) 197 + if err == nil { 198 + // 2. Restore the body so it can be read again downstream 199 + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 200 + 201 + // 3. Now parse the buffer for your local check 202 + var jsonInput struct { 203 + Repo string `json:"repo"` 204 + } 205 + 206 + // Use json.Unmarshal directly on the buffer 207 + // (or continue using ShouldBindJSON if you prefer, 208 + // though Unmarshal is more efficient here since we already have the bytes) 209 + if err := json.Unmarshal(bodyBytes, &jsonInput); err == nil && jsonInput.Repo != "" { 210 + handleSyncXRPC(c, jsonInput.Repo) 211 + return 212 + } 213 + } 214 + } 185 215 186 216 fullHost := c.Request.Host 187 217 handleSyncXRPC(c, "did:web:" + fullHost)
+1
backend/peers.go
··· 20 20 type signalMsg struct { 21 21 Type string `json:"type"` 22 22 Challenge string `json:"challenge,omitempty"` 23 + Auth string `json:"auth,omitempty"` 23 24 DID string `json:"did,omitempty"` 24 25 Sig string `json:"sig,omitempty"` 25 26 PublicKey map[string]any `json:"publicKeyJwk,omitempty"`
+4 -1
backend/webrtc.go
··· 20 20 // 21 21 // The pending channel carries signalMsg for the header frame, then the 22 22 // peer's readPump sends the binary body via a separate binaryPending channel. 23 - func requestFromPeer(ctx context.Context, p *peer, method string, params map[string]string, body []byte) (dcResponse, error) { 23 + func requestFromPeer(ctx context.Context, p *peer, method string, params map[string]string, body []byte, auth string) (dcResponse, error) { 24 24 requestID := randomHex(16) 25 25 26 26 headerCh := make(chan signalMsg, 1) ··· 46 46 Params: params, 47 47 } 48 48 49 + if len(auth) > 0 { 50 + msg.Auth = auth 51 + } 49 52 // Only set Length if it's above 0 50 53 if len(body) > 0 { 51 54 msg.Length = len(body)
+13
extension/pod-relay.js
··· 1 1 import { generateTid } from './atproto-repo.js'; 2 2 import { signJwtES256 } from './background.js'; 3 3 import { decodeProtectedHeader, calculateJwkThumbprint } from './utils.js'; 4 + import { pushToRelay } from './ingest.js'; 4 5 5 6 const DOMAIN = 'https://localpds.at'; 6 7 const SIGNAL_URL = 'wss://localpds.at/signal'; ··· 203 204 204 205 case 'com.atproto.repo.listMissingBlobs': 205 206 return json({ blobs: [] }); 207 + 208 + case 'com.atproto.repo.putRecord': 209 + console.log(body); 210 + const { collection, rkey, record } = body; 211 + const prevCid = repo.headCid; 212 + const prevDataCid = repo.mstRootCid; 213 + const prevRev = repo.rev; 214 + const result = await repo.putRecord(collection, rkey, record); 215 + await pushToRelay({ repo, did: repo.did, prevCommitCid: prevCid, prevDataCid, prevRev, 216 + ops: [{ action: 'update', collection, rkey, cid: result.cid }] }); 217 + return json(result); 218 + 206 219 207 220 // ---------------------------------------------------------------- 208 221 // Default