···135135}
136136137137// FetchAuthServerMeta fetches OAuth authorization server metadata from a PDS.
138138+// Per the ATProto OAuth spec, the PDS may delegate to a separate authorization server.
139139+// We discover it via the protected resource metadata if the PDS doesn't host its own.
138140func FetchAuthServerMeta(pdsURL string) (*AuthServerMeta, error) {
139139- url := strings.TrimRight(pdsURL, "/") + "/.well-known/oauth-authorization-server"
140140- resp, err := httpClient.Get(url)
141141+ base := strings.TrimRight(pdsURL, "/")
142142+143143+ // First try the PDS directly (works for self-hosted PDSes).
144144+ directURL := base + "/.well-known/oauth-authorization-server"
145145+ resp, err := httpClient.Get(directURL)
141146 if err != nil {
142147 return nil, fmt.Errorf("fetch auth server meta from %s: %w", pdsURL, err)
143148 }
144149 defer resp.Body.Close()
145145- if resp.StatusCode != http.StatusOK {
146146- return nil, fmt.Errorf("fetch auth server meta from %s: HTTP %d", pdsURL, resp.StatusCode)
150150+151151+ if resp.StatusCode == http.StatusOK {
152152+ var meta AuthServerMeta
153153+ if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
154154+ return nil, fmt.Errorf("decode auth server meta: %w", err)
155155+ }
156156+ if meta.PushedAuthorizationRequestEndpoint == "" {
157157+ return nil, fmt.Errorf("auth server at %s does not support PAR", pdsURL)
158158+ }
159159+ return &meta, nil
160160+ }
161161+ resp.Body.Close()
162162+163163+ // PDS doesn't host its own auth server — discover it via protected resource metadata.
164164+ prURL := base + "/.well-known/oauth-protected-resource"
165165+ prResp, err := httpClient.Get(prURL)
166166+ if err != nil {
167167+ return nil, fmt.Errorf("fetch protected resource meta from %s: %w", pdsURL, err)
168168+ }
169169+ defer prResp.Body.Close()
170170+ if prResp.StatusCode != http.StatusOK {
171171+ return nil, fmt.Errorf("fetch protected resource meta from %s: HTTP %d", pdsURL, prResp.StatusCode)
172172+ }
173173+174174+ var prMeta struct {
175175+ AuthorizationServers []string `json:"authorization_servers"`
176176+ }
177177+ if err := json.NewDecoder(prResp.Body).Decode(&prMeta); err != nil {
178178+ return nil, fmt.Errorf("decode protected resource meta: %w", err)
179179+ }
180180+ if len(prMeta.AuthorizationServers) == 0 {
181181+ return nil, fmt.Errorf("no authorization_servers in protected resource meta for %s", pdsURL)
182182+ }
183183+184184+ authServerURL := strings.TrimRight(prMeta.AuthorizationServers[0], "/")
185185+ metaURL := authServerURL + "/.well-known/oauth-authorization-server"
186186+ metaResp, err := httpClient.Get(metaURL)
187187+ if err != nil {
188188+ return nil, fmt.Errorf("fetch auth server meta from %s: %w", authServerURL, err)
189189+ }
190190+ defer metaResp.Body.Close()
191191+ if metaResp.StatusCode != http.StatusOK {
192192+ return nil, fmt.Errorf("fetch auth server meta from %s: HTTP %d", authServerURL, metaResp.StatusCode)
147193 }
148194149195 var meta AuthServerMeta
150150- if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
196196+ if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil {
151197 return nil, fmt.Errorf("decode auth server meta: %w", err)
152198 }
153199 if meta.PushedAuthorizationRequestEndpoint == "" {
154154- return nil, fmt.Errorf("auth server at %s does not support PAR", pdsURL)
200200+ return nil, fmt.Errorf("auth server at %s does not support PAR", authServerURL)
155201 }
156202 return &meta, nil
157203}
+14-5
internal/atproto/xrpc/client.go
···8888 c.db.UpdateATProtoTokens(c.userID, c.session.AccessToken, c.session.RefreshToken, newNonce, c.session.ExpiresAt)
8989 }
90909191- // On 401, refresh token and retry once
9191+ // On 401, retry once — nonce error just needs the updated nonce, token errors need a refresh.
9292 if resp.StatusCode == http.StatusUnauthorized {
9393+ respBody, _ := io.ReadAll(resp.Body)
9394 resp.Body.Close()
9494- if err := atproto.RefreshAccessToken(c.db, c.session); err != nil {
9595- return nil, fmt.Errorf("refresh after 401: %w", err)
9595+9696+ var errPayload struct {
9797+ Error string `json:"error"`
9898+ }
9999+ json.Unmarshal(respBody, &errPayload)
100100+101101+ if errPayload.Error != "use_dpop_nonce" {
102102+ // Expired/invalid token — refresh and retry.
103103+ if err := atproto.RefreshAccessToken(c.db, c.session); err != nil {
104104+ return nil, fmt.Errorf("refresh after 401: %w", err)
105105+ }
96106 }
107107+ // For use_dpop_nonce the nonce was already updated above; just retry.
971089898- // Re-read the body if it was provided — caller must handle this
9999- // For our use case, body is always a bytes.Reader or nil
100109 if body != nil {
101110 if seeker, ok := body.(io.Seeker); ok {
102111 seeker.Seek(0, io.SeekStart)
+25-9
internal/handler/handler.go
···2233import (
44 "crypto/rand"
55+ "encoding/base64"
56 "encoding/hex"
67 "encoding/json"
78 "fmt"
···1314 "strings"
1415 "time"
15161616- "github.com/golang-jwt/jwt/v5"
1717 "github.com/gorilla/websocket"
18181919 "github.com/limeleaf/diffdown/internal/atproto"
···998998999999 did, name, err := h.validateWSToken(accessToken, dpopProof)
10001000 if err != nil {
10011001+ log.Printf("CollaboratorWebSocket: token validation failed for rkey %s: %v", rKey, err)
10011002 http.Error(w, "Invalid tokens", http.StatusUnauthorized)
10021003 return
10031004 }
1004100510051006 user, err := h.DB.GetUserByDID(did)
10061007 if err != nil {
10081008+ log.Printf("CollaboratorWebSocket: user not found for DID %s: %v", did, err)
10071009 http.Error(w, "No user found", http.StatusUnauthorized)
10081010 return
10091011 }
1010101210111013 session, err := h.DB.GetATProtoSession(user.ID)
10121014 if err != nil || session == nil {
10151015+ log.Printf("CollaboratorWebSocket: no ATProto session for user %s: %v", user.ID, err)
10131016 http.Error(w, "No ATProto session", http.StatusUnauthorized)
10141017 return
10151018 }
···10281031 }
10291032 docClient, err = h.xrpcClient(ownerUser.ID)
10301033 if err != nil {
10341034+ log.Printf("CollaboratorWebSocket: xrpc client for owner %s: %v", ownerDID, err)
10311035 http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError)
10321036 return
10331037 }
···10351039 } else {
10361040 docClient, err = h.xrpcClient(session.UserID)
10371041 if err != nil {
10421042+ log.Printf("CollaboratorWebSocket: xrpc client for user %s: %v", session.UserID, err)
10381043 http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError)
10391044 return
10401045 }
···1043104810441049 value, _, err := docClient.GetRecord(docRepoDID, collectionDocument, rKey)
10451050 if err != nil {
10511051+ log.Printf("CollaboratorWebSocket: GetRecord %s/%s: %v", docRepoDID, rKey, err)
10461052 http.Error(w, "Document not found", http.StatusNotFound)
10471053 return
10481054 }
10491055 doc := &model.Document{}
10501056 if err := json.Unmarshal(value, doc); err != nil {
10571057+ log.Printf("CollaboratorWebSocket: unmarshal doc %s: %v", rKey, err)
10511058 http.Error(w, "Invalid document", http.StatusInternalServerError)
10521059 return
10531060 }
···10611068 }
10621069 }
10631070 if !isCollaborator {
10711071+ log.Printf("CollaboratorWebSocket: DID %s not a collaborator on %s/%s", did, docRepoDID, rKey)
10641072 http.Error(w, "Not a collaborator", http.StatusForbidden)
10651073 return
10661074 }
···10881096 wsClient.ReadPump()
10891097}
1090109810911091-func (h *Handler) validateWSToken(accessToken, dpopProof string) (string, string, error) {
10921092- claims := &jwt.MapClaims{}
10931093- parser := jwt.Parser{}
10941094- _, _, err := parser.ParseUnverified(accessToken, claims)
10991099+func (h *Handler) validateWSToken(accessToken, _ string) (string, string, error) {
11001100+ // ATProto JWTs use ES256K which golang-jwt doesn't register by default.
11011101+ // We only need the sub claim, so decode the payload directly.
11021102+ parts := strings.SplitN(accessToken, ".", 3)
11031103+ if len(parts) != 3 {
11041104+ return "", "", fmt.Errorf("malformed token")
11051105+ }
11061106+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
10951107 if err != nil {
10961096- return "", "", fmt.Errorf("parse token: %w", err)
11081108+ return "", "", fmt.Errorf("decode token payload: %w", err)
11091109+ }
11101110+ var claims map[string]interface{}
11111111+ if err := json.Unmarshal(payload, &claims); err != nil {
11121112+ return "", "", fmt.Errorf("parse token claims: %w", err)
10971113 }
1098111410991099- did, ok := (*claims)["sub"].(string)
11001100- if !ok {
11151115+ did, ok := claims["sub"].(string)
11161116+ if !ok || did == "" {
11011117 return "", "", fmt.Errorf("no sub in token")
11021118 }
11031119···11151131 return "", "", fmt.Errorf("session expired")
11161132 }
1117113311181118- name, _ := (*claims)["name"].(string)
11341134+ name, _ := claims["name"].(string)
11191135 return did, name, nil
11201136}
11211137
+54
migrations/001_baseline.sql
···11+-- 001_baseline.sql
22+-- Consolidated baseline schema replacing migrations 001–008.
33+44+CREATE TABLE users (
55+ id TEXT PRIMARY KEY,
66+ did TEXT UNIQUE NOT NULL
77+);
88+99+CREATE TABLE atproto_sessions (
1010+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
1111+ did TEXT NOT NULL,
1212+ pds_url TEXT NOT NULL,
1313+ access_token TEXT NOT NULL,
1414+ refresh_token TEXT NOT NULL,
1515+ dpop_key_jwk TEXT NOT NULL,
1616+ dpop_nonce TEXT DEFAULT '',
1717+ token_endpoint TEXT NOT NULL,
1818+ expires_at DATETIME NOT NULL,
1919+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
2020+);
2121+2222+CREATE TABLE invites (
2323+ id TEXT PRIMARY KEY,
2424+ document_rkey TEXT NOT NULL,
2525+ token TEXT NOT NULL UNIQUE,
2626+ created_by_did TEXT NOT NULL,
2727+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
2828+ expires_at DATETIME NOT NULL
2929+);
3030+3131+CREATE INDEX idx_invites_document ON invites(document_rkey);
3232+CREATE INDEX idx_invites_token ON invites(token);
3333+3434+CREATE TABLE doc_steps (
3535+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3636+ doc_rkey TEXT NOT NULL,
3737+ version INTEGER NOT NULL,
3838+ step_json TEXT NOT NULL,
3939+ client_id TEXT NOT NULL,
4040+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
4141+ UNIQUE(doc_rkey, version)
4242+);
4343+4444+CREATE INDEX idx_doc_steps_rkey_version ON doc_steps(doc_rkey, version);
4545+4646+CREATE TABLE collaborations (
4747+ collaborator_did TEXT NOT NULL,
4848+ owner_did TEXT NOT NULL,
4949+ document_rkey TEXT NOT NULL,
5050+ added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5151+ PRIMARY KEY (collaborator_did, owner_did, document_rkey)
5252+);
5353+5454+CREATE INDEX idx_collaborations_did_added ON collaborations(collaborator_did, added_at DESC);
-73
migrations/001_initial.sql
···11--- 001_initial.sql
22-33-CREATE TABLE IF NOT EXISTS users (
44- id TEXT PRIMARY KEY,
55- name TEXT NOT NULL,
66- email TEXT UNIQUE NOT NULL,
77- password_hash TEXT,
88- avatar_url TEXT DEFAULT '',
99- oauth_provider TEXT,
1010- oauth_id TEXT,
1111- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
1212-);
1313-1414-CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oauth ON users(oauth_provider, oauth_id)
1515- WHERE oauth_provider IS NOT NULL;
1616-1717-CREATE TABLE IF NOT EXISTS repos (
1818- id TEXT PRIMARY KEY,
1919- name TEXT NOT NULL,
2020- slug TEXT NOT NULL,
2121- description TEXT DEFAULT '',
2222- owner_id TEXT NOT NULL REFERENCES users(id),
2323- visibility TEXT DEFAULT 'private' CHECK(visibility IN ('private','public','unlisted')),
2424- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
2525- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
2626-);
2727-2828-CREATE UNIQUE INDEX IF NOT EXISTS idx_repos_owner_slug ON repos(owner_id, slug);
2929-3030-CREATE TABLE IF NOT EXISTS repo_collaborators (
3131- repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
3232- user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
3333- role TEXT NOT NULL DEFAULT 'viewer' CHECK(role IN ('owner','editor','commenter','viewer')),
3434- PRIMARY KEY (repo_id, user_id)
3535-);
3636-3737-CREATE TABLE IF NOT EXISTS files (
3838- id TEXT PRIMARY KEY,
3939- repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
4040- path TEXT NOT NULL,
4141- content TEXT DEFAULT '',
4242- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
4343- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
4444-);
4545-4646-CREATE UNIQUE INDEX IF NOT EXISTS idx_files_repo_path ON files(repo_id, path);
4747-4848-CREATE TABLE IF NOT EXISTS versions (
4949- id TEXT PRIMARY KEY,
5050- file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
5151- content TEXT NOT NULL,
5252- author_id TEXT NOT NULL REFERENCES users(id),
5353- message TEXT DEFAULT '',
5454- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
5555-);
5656-5757-CREATE INDEX IF NOT EXISTS idx_versions_file ON versions(file_id, created_at DESC);
5858-5959-CREATE TABLE IF NOT EXISTS comments (
6060- id TEXT PRIMARY KEY,
6161- file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
6262- version_id TEXT REFERENCES versions(id),
6363- line_start INTEGER NOT NULL,
6464- line_end INTEGER NOT NULL,
6565- content_hash TEXT DEFAULT '',
6666- body TEXT NOT NULL,
6767- author_id TEXT NOT NULL REFERENCES users(id),
6868- parent_id TEXT REFERENCES comments(id),
6969- resolved INTEGER DEFAULT 0,
7070- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
7171-);
7272-7373-CREATE INDEX IF NOT EXISTS idx_comments_file ON comments(file_id);
-3
migrations/002_add_did.sql
···11--- 002_add_did.sql
22-ALTER TABLE users ADD COLUMN did TEXT;
33-CREATE UNIQUE INDEX IF NOT EXISTS idx_users_did ON users(did) WHERE did IS NOT NULL;
-14
migrations/003_atproto_sessions.sql
···11-CREATE TABLE IF NOT EXISTS atproto_sessions (
22- user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
33- did TEXT NOT NULL,
44- pds_url TEXT NOT NULL,
55- access_token TEXT NOT NULL,
66- refresh_token TEXT NOT NULL,
77- dpop_key_jwk TEXT NOT NULL,
88- dpop_nonce TEXT DEFAULT '',
99- token_endpoint TEXT NOT NULL,
1010- expires_at DATETIME NOT NULL,
1111- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
1212-);
1313-1414-ALTER TABLE users ADD COLUMN pds_url TEXT DEFAULT '';
-5
migrations/004_drop_content_tables.sql
···11-DROP TABLE IF EXISTS comments;
22-DROP TABLE IF EXISTS versions;
33-DROP TABLE IF EXISTS files;
44-DROP TABLE IF EXISTS repo_collaborators;
55-DROP TABLE IF EXISTS repos;
-13
migrations/005_create_invites.sql
···11--- 005_create_invites.sql
22-33-CREATE TABLE IF NOT EXISTS invites (
44- id TEXT PRIMARY KEY,
55- document_rkey TEXT NOT NULL,
66- token TEXT NOT NULL UNIQUE,
77- created_by_did TEXT NOT NULL,
88- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
99- expires_at DATETIME NOT NULL
1010-);
1111-1212-CREATE INDEX IF NOT EXISTS idx_invites_document ON invites(document_rkey);
1313-CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
-12
migrations/006_doc_steps.sql
···11--- migrations/006_doc_steps.sql
22-CREATE TABLE IF NOT EXISTS doc_steps (
33- id INTEGER PRIMARY KEY AUTOINCREMENT,
44- doc_rkey TEXT NOT NULL,
55- version INTEGER NOT NULL,
66- step_json TEXT NOT NULL,
77- client_id TEXT NOT NULL,
88- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
99- UNIQUE(doc_rkey, version)
1010-);
1111-1212-CREATE INDEX IF NOT EXISTS idx_doc_steps_rkey_version ON doc_steps(doc_rkey, version);
-25
migrations/007_stateless_users.sql
···11--- 007_stateless_users.sql
22--- Remove cached profile data, make auth stateless
33-44--- Recreate users table with minimal data (DID only)
55--- We need to preserve existing data, so we'll create new table and migrate
66-77-CREATE TABLE users_new (
88- id TEXT PRIMARY KEY,
99- did TEXT UNIQUE NOT NULL
1010-);
1111-1212--- Migrate existing users - copy only id and did
1313-INSERT INTO users_new (id, did)
1414-SELECT id, COALESCE(did, email) as did
1515-FROM users
1616-WHERE did IS NOT NULL OR email LIKE 'did:%';
1717-1818--- Drop old users table
1919-DROP TABLE users;
2020-2121--- Rename new table
2222-ALTER TABLE users_new RENAME TO users;
2323-2424--- Note: We keep atproto_sessions as-is since it stores the OAuth tokens
2525--- needed to authenticate with the user's PDS at runtime
-10
migrations/008_collaborations.sql
···11-CREATE TABLE IF NOT EXISTS collaborations (
22- collaborator_did TEXT NOT NULL,
33- owner_did TEXT NOT NULL,
44- document_rkey TEXT NOT NULL,
55- added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
66- PRIMARY KEY (collaborator_did, owner_did, document_rkey)
77-);
88-99-CREATE INDEX IF NOT EXISTS idx_collaborations_did_added
1010- ON collaborations (collaborator_did, added_at DESC);
+1-1
templates/about.html
···3333 </div>
3434 <div class="about-col">
3535 <h2>Status</h2>
3636- <p>As I said, I am not an engineer. My goal was to create something simple that worked, and I achieved that. But 95% of Diffdown was written by <a href="https://en.wikipedia.org/wiki/Large_language_model">LLMs</a>. I am not qualified to assess the security or robustness of the code and don't claim that it is production quality. I would <em>like</em> it to be, so <a href="https://tangled.org/diffdown.com/diffdown-app/">contributors are welcome</a>!</p>
3636+ <p>As I said, I am not an engineer. My goal was to create something simple that worked, and I achieved that. But 95% of Diffdown was written by <a href="https://en.wikipedia.org/wiki/Large_language_model">LLMs</a>. I am not qualified to assess the security or robustness of the code and don't claim that it is production quality. I would <em>like</em> it to be, so <a href="https://tangled.org/diffdown.com/diffdown-app/">contributions from real engineers are welcome</a>!</p>
3737 <p>Functionally, the app is alpha quality, at best. Expect bugs, flakiness, breaking changes, and limited features.</p>
3838 <p>The good news is that any documents you create will be stored on your AT Proto PDS, so even if Diffdown goes away, you will still have your documents and comments.</p>
3939 <p><strong class="warning">Important:</strong> Because AT Proto does not support private records (<a href="https://atproto.wiki/en/working-groups/private-data">yet</a>), any documents you create will be visible to the world (not on diffdown.com, but with a PDS record viewer, <a href="https://atproto.at/viewer?uri=did:plc:za4vlvbizdstoym7lpymc5q5/com.diffdown.document/3mhg62vlznz24">see this example</a>).</p>