| 1 | package main |
| 2 | |
| 3 | // π€ The account-merge operation (issue #1098): "looks like you have two |
| 4 | // accounts β combine them?". One endpoint: |
| 5 | // |
| 6 | // POST /api/account/merge {absorb_proof: "<ceremony proof for the account being absorbed>"} |
| 7 | // |
| 8 | // The DIVISION OF LABOUR is the design: the CLIENT merges the vaults first β |
| 9 | // decrypt both locally, union the saved spaces, re-encrypt under the |
| 10 | // survivor's DEK, PUT to the survivor, then delete the now-redundant absorbed |
| 11 | // blob via the existing ceremony-gated DELETE /api/blob. Only then does it |
| 12 | // call this endpoint, which moves the SERVER-side account furniture β every |
| 13 | // anchor, passkey credential, and device link token β onto the survivor and |
| 14 | // retires the emptied absorbed account. The server never sees vault contents |
| 15 | // (the one rule), and the client-first ordering means merge itself destroys |
| 16 | // nothing but an empty shell: if the absorbed account still holds a blob, |
| 17 | // this refuses with a 409 and the plain instruction to move the spaces over |
| 18 | // first β which also makes the whole op idempotent-safe (a retried merge |
| 19 | // finds nothing left to lose). |
| 20 | // |
| 21 | // Auth is BOTH-SIDES-PROOFS. The survivor side: Authorization carries its |
| 22 | // link token (names the account) AND X-Ceremony-Proof carries its own fresh |
| 23 | // ceremony proof β merging moves sign-in methods, so like every |
| 24 | // access-changing op a stolen link token alone must never suffice. The |
| 25 | // absorbed side: the body's absorb_proof, a fresh proof minted by verifying |
| 26 | // any of THAT account's anchors (or a passkey ceremony). Both proofs must be |
| 27 | // live; proving you hold two accounts is exactly two fresh ceremonies. |
| 28 | // |
| 29 | // Device link tokens MOVE rather than die, so the absorbed account's other |
| 30 | // devices keep working β same tokens, now signed into the survivor and seeing |
| 31 | // the merged vault. |
| 32 | |
| 33 | import ( |
| 34 | "encoding/json" |
| 35 | "errors" |
| 36 | "net/http" |
| 37 | ) |
| 38 | |
| 39 | // mergeAccountHandler is POST /api/account/merge β see the file comment for |
| 40 | // the contract. The settings surface (#1098's kebab) and the 409 "already |
| 41 | // signs in somewhere else" flow both land here once the client-side vault |
| 42 | // union is done. |
| 43 | func (s *Server) mergeAccountHandler(w http.ResponseWriter, r *http.Request) { |
| 44 | survivor, ok := s.linkUser(r) |
| 45 | if !ok { |
| 46 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 47 | return |
| 48 | } |
| 49 | // The survivor's OWN fresh proof β and it must be for the bearer's |
| 50 | // account, or it's someone else's ceremony riding this session. |
| 51 | if proofUID, ok := s.ceremonyUser(r); !ok || proofUID != survivor { |
| 52 | http.Error(w, "Confirm it's you first", http.StatusUnauthorized) |
| 53 | return |
| 54 | } |
| 55 | var body struct { |
| 56 | AbsorbProof string `json:"absorb_proof"` |
| 57 | } |
| 58 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.AbsorbProof == "" { |
| 59 | http.Error(w, "absorb_proof required", http.StatusBadRequest) |
| 60 | return |
| 61 | } |
| 62 | absorbed, ok := s.keys.ceremonyUser(body.AbsorbProof) |
| 63 | if !ok { |
| 64 | http.Error(w, "Confirm the other account too", http.StatusUnauthorized) |
| 65 | return |
| 66 | } |
| 67 | if absorbed == survivor { |
| 68 | http.Error(w, "those are the same account β nothing to combine", http.StatusBadRequest) |
| 69 | return |
| 70 | } |
| 71 | // Precondition: the absorbed vault must already be empty (client-first |
| 72 | // ordering, above). The store re-checks under its own atomicity β this |
| 73 | // early read just gives the common case its answer without side effects. |
| 74 | if _, _, found, err := s.store.getBlob(absorbed); err != nil { |
| 75 | http.Error(w, "db error", http.StatusInternalServerError) |
| 76 | return |
| 77 | } else if found { |
| 78 | http.Error(w, "That account still has spaces saved to it β move them over first", http.StatusConflict) |
| 79 | return |
| 80 | } |
| 81 | moved, err := s.store.mergeAccounts(survivor, absorbed) |
| 82 | if errors.Is(err, errAbsorbedHasBlob) { |
| 83 | // The store's belt caught a blob written between the check above and |
| 84 | // the merge. |
| 85 | http.Error(w, "That account still has spaces saved to it β move them over first", http.StatusConflict) |
| 86 | return |
| 87 | } |
| 88 | if err != nil { |
| 89 | http.Error(w, "db error", http.StatusInternalServerError) |
| 90 | return |
| 91 | } |
| 92 | writeJSON(w, http.StatusOK, map[string]any{"merged": true, "moved": moved}) |
| 93 | } |
| 94 | |