package main // 🤖 The account-merge operation (issue #1098): "looks like you have two // accounts — combine them?". One endpoint: // // POST /api/account/merge {absorb_proof: ""} // // The DIVISION OF LABOUR is the design: the CLIENT merges the vaults first — // decrypt both locally, union the saved spaces, re-encrypt under the // survivor's DEK, PUT to the survivor, then delete the now-redundant absorbed // blob via the existing ceremony-gated DELETE /api/blob. Only then does it // call this endpoint, which moves the SERVER-side account furniture — every // anchor, passkey credential, and device link token — onto the survivor and // retires the emptied absorbed account. The server never sees vault contents // (the one rule), and the client-first ordering means merge itself destroys // nothing but an empty shell: if the absorbed account still holds a blob, // this refuses with a 409 and the plain instruction to move the spaces over // first — which also makes the whole op idempotent-safe (a retried merge // finds nothing left to lose). // // Auth is BOTH-SIDES-PROOFS. The survivor side: Authorization carries its // link token (names the account) AND X-Ceremony-Proof carries its own fresh // ceremony proof — merging moves sign-in methods, so like every // access-changing op a stolen link token alone must never suffice. The // absorbed side: the body's absorb_proof, a fresh proof minted by verifying // any of THAT account's anchors (or a passkey ceremony). Both proofs must be // live; proving you hold two accounts is exactly two fresh ceremonies. // // Device link tokens MOVE rather than die, so the absorbed account's other // devices keep working — same tokens, now signed into the survivor and seeing // the merged vault. import ( "encoding/json" "errors" "net/http" ) // mergeAccountHandler is POST /api/account/merge — see the file comment for // the contract. The settings surface (#1098's kebab) and the 409 "already // signs in somewhere else" flow both land here once the client-side vault // union is done. func (s *Server) mergeAccountHandler(w http.ResponseWriter, r *http.Request) { survivor, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } // The survivor's OWN fresh proof — and it must be for the bearer's // account, or it's someone else's ceremony riding this session. if proofUID, ok := s.ceremonyUser(r); !ok || proofUID != survivor { http.Error(w, "Confirm it's you first", http.StatusUnauthorized) return } var body struct { AbsorbProof string `json:"absorb_proof"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.AbsorbProof == "" { http.Error(w, "absorb_proof required", http.StatusBadRequest) return } absorbed, ok := s.keys.ceremonyUser(body.AbsorbProof) if !ok { http.Error(w, "Confirm the other account too", http.StatusUnauthorized) return } if absorbed == survivor { http.Error(w, "those are the same account — nothing to combine", http.StatusBadRequest) return } // Precondition: the absorbed vault must already be empty (client-first // ordering, above). The store re-checks under its own atomicity — this // early read just gives the common case its answer without side effects. if _, _, found, err := s.store.getBlob(absorbed); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } else if found { http.Error(w, "That account still has spaces saved to it — move them over first", http.StatusConflict) return } moved, err := s.store.mergeAccounts(survivor, absorbed) if errors.Is(err, errAbsorbedHasBlob) { // The store's belt caught a blob written between the check above and // the merge. http.Error(w, "That account still has spaces saved to it — move them over first", http.StatusConflict) return } if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{"merged": true, "moved": moved}) }