eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package 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
33import (
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.
43func (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