1 import Base 2 import ./json.bend as Json 3 import ./swbase.bend as B 4 import ./store/base.bend as SBase 5 import ./store/plan.bend as SPlan 6 import ./store/seq.bend as SSeq 7 8 # Switch, part 2: what a credential is and whose it is (pure). 9 10 # What the live credential is, compared with the slot it is filed under. 11 12 type CKind is Data: 13 KOwnBytes{} 14 KOwnFamily{} 15 KWiped{} 16 KForeignSynced{slot: String} 17 KForeign{slot: String} 18 KAlien{} 19 KUnresolved{} 20 21 # Another slot's stored credential. 22 type Other is Data: 23 Other{slot: String, creds: String} 24 25 def is_shared_key(+k: String) -> Bool: 26 String.eq(k, "mcpOAuth") || String.eq(k, "mcpOAuthClientConfig") || String.eq(k, "mcpXaaIdp") || String.eq(k, "mcpXaaIdpConfig") || String.eq(k, "pluginSecrets") 27 28 def keep_field(+drop: Bool, +k: String, +v: Json.Json, +rest: Json.Json) -> Json.Json: 29 match drop: 30 case True{}: 31 rest 32 case False{}: 33 Json.JField{k, v, rest} 34 35 # The fields of an object chain that are (or are not) shared across accounts. 36 def pick_fields(+fields: Json.Json, +shared: Bool) -> Json.Json: 37 match fields: 38 case Json.JField{k, v, t}: 39 keep_field(Bool.not(SBase.sel.b(shared, is_shared_key(k), Bool.not(is_shared_key(k)))), k, v, pick_fields(t, shared)) 40 case _: 41 Json.JNil{} 42 43 def append_fields(+a: Json.Json, +b: Json.Json) -> Json.Json: 44 match a: 45 case Json.JField{k, v, t}: 46 Json.JField{k, v, append_fields(t, b)} 47 case _: 48 b 49 50 def has_oauth(+fields: Json.Json) -> Bool: 51 match fields: 52 case Json.JField{k, v, t}: 53 String.eq(k, "claudeAiOauth") || has_oauth(t) 54 case _: 55 False{} 56 57 def merge3(+oauth: Bool, +lf: Json.Json, +tf: Json.Json, +target_text: String) -> String: 58 match oauth: 59 case True{}: 60 Json.emit_py(Json.JObj{append_fields(pick_fields(tf, False{}), pick_fields(lf, True{}))}) 61 case False{}: 62 target_text 63 64 def merge2(+live: Json.Json, +target: Json.Json, +target_text: String) -> String: 65 match live target: 66 case Json.JObj{lf} Json.JObj{tf}: 67 merge3(has_oauth(tf), lf, tf, target_text) 68 case _ _: 69 target_text 70 71 def merge1(live: Result<&2, &2, String, Json.Json>, target: Result<&2, &2, String, Json.Json>, +target_text: String) -> String: 72 match live target: 73 case Done{l} Done{t}: 74 merge2(l, t, target_text) 75 case _ _: 76 target_text 77 78 # The credential to write for `target` while `live` is replaced: Claude Code's 79 # account-independent fields (MCP logins, plugin secrets) ride along from the 80 # live one. A live API key or non-JSON leaves the target as is. 81 def prepare_activation(+target: String, +live: String) -> String: 82 merge1(Json.parse(live), Json.parse(target), target) 83 84 def oauth_of(+creds: Json.Json) -> Json.Json: 85 SSeq.get_or("claudeAiOauth", creds, Json.JNull{}) 86 87 def refresh_of2(r: Result<&2, &2, String, Json.Json>) -> String: 88 match r: 89 case Done{j}: 90 SSeq.field_str(oauth_of(j), "refreshToken") 91 case Fail{e}: 92 "" 93 94 def refresh_of(+text: String) -> String: 95 refresh_of2(Json.parse(text)) 96 97 def access_of2(r: Result<&2, &2, String, Json.Json>) -> String: 98 match r: 99 case Done{j}: 100 SSeq.field_str(oauth_of(j), "accessToken") 101 case Fail{e}: 102 "" 103 104 def access_of(+text: String) -> String: 105 access_of2(Json.parse(text)) 106 107 def is_object(+j: Json.Json) -> Bool: 108 match j: 109 case Json.JObj{f}: 110 True{} 111 case _: 112 False{} 113 114 def is_oauth_obj2(r: Result<&2, &2, String, Json.Json>) -> Bool: 115 match r: 116 case Done{j}: 117 is_object(oauth_of(j)) 118 case Fail{e}: 119 False{} 120 121 def is_oauth_obj(+text: String) -> Bool: 122 is_oauth_obj2(Json.parse(text)) 123 124 def same_lineage2(+ra: String, +rb: String, +a: String, +b: String) -> Bool: 125 SBase.sel.b(SBase.nonempty(ra) || SBase.nonempty(rb), String.eq(ra, rb), String.eq(a, b)) 126 127 # The credential fingerprint rule: the refresh token when there is one, else 128 # the whole text. Equal fingerprints are equal tokens, so compare the tokens. 129 def same_lineage(+a: String, +b: String) -> Bool: 130 same_lineage2(refresh_of(a), refresh_of(b), a, b) 131 132 # Claude Code empties both tokens in place when a refresh is rejected. 133 def wiped(+text: String) -> Bool: 134 is_oauth_obj(text) && String.is_empty(access_of(text)) && String.is_empty(refresh_of(text)) 135 136 def foreign_slot(+live: String, +others: List<&2, Other>) -> Maybe<&2, String>: 137 match others: 138 case Nil{}: 139 None{} 140 case Con{Other{s, c}, t}: 141 SSeq.pick_slot(SBase.nonempty(c) && same_lineage(live, c), s, foreign_slot(live, t)) 142 143 def classify3(+foreign: Maybe<&2, String>) -> CKind: 144 match foreign: 145 case Some{s}: 146 KForeignSynced{s} 147 case None{}: 148 KUnresolved{} 149 150 def classify2b(+is_wiped: Bool, +foreign: Maybe<&2, String>) -> CKind: 151 match is_wiped: 152 case True{}: 153 KWiped{} 154 case False{}: 155 classify3(foreign) 156 157 def classify2(+backup_nonempty: Bool, +eq: Bool, +family: Bool, +is_wiped: Bool, +foreign: Maybe<&2, String>) -> CKind: 158 match backup_nonempty eq family: 159 case True{} True{} _: 160 KOwnBytes{} 161 case True{} False{} True{}: 162 KOwnFamily{} 163 case _ _ _: 164 classify2b(is_wiped, foreign) 165 166 # `_classify_outgoing_credential` without the profile oracle: byte equality, 167 # refresh-token lineage, wiped tokens, another slot already holding this 168 # lineage, else unresolved. 169 def classify(+live: String, +backup: String, +others: List<&2, Other>) -> CKind: 170 classify2(SBase.nonempty(backup), String.eq(backup, live), same_lineage(backup, live), wiped(live), foreign_slot(live, others)) 171 172 def kind_warning(+k: CKind, +cur: String) -> List<&2, String>: 173 match k: 174 case KWiped{}: 175 ["The live credential's tokens were wiped (Claude Code clears them when a refresh is rejected). Account-" ++ cur ++ "'s stored backup was kept. If the account cannot authenticate after switching back, log in with Claude Code and run: cswap add"] 176 case KForeignSynced{s}: 177 ["Credential ownership mismatch detected. The live credential already matches Account-" ++ s ++ "'s stored backup, so nothing was written into Account-" ++ cur ++ "."] 178 case KForeign{s}: 179 ["Credential ownership mismatch detected. The live credential was preserved and was not written into Account-" ++ cur ++ ". If Account-" ++ s ++ " later cannot authenticate, log in as it and run: cswap add --slot " ++ s] 180 case KAlien{}: 181 ["The live login does not match a managed account. It was preserved and not written into Account-" ++ cur ++ ". If you need that account, log in as it and run: cswap add"] 182 case _: 183 [] 184 185 def org_compatible(+r_org: String, +own_org: String) -> Bool: 186 String.is_empty(r_org) || String.is_empty(own_org) || String.eq(r_org, own_org) 187 188 def sel_ck(+c: Bool, +x: CKind, +y: CKind) -> CKind: 189 match c: 190 case True{}: 191 x 192 case False{}: 193 y 194 195 def foreign_or_synced(+n: String, +live: String, +others: List<&2, Other>) -> CKind: 196 sel_ck(String.eq(SSeq.str_or(foreign_slot(live, others), ""), n), KForeignSynced{n}, KForeign{n}) 197 198 def stored_uuid(+seq: Json.Json, +slot: String) -> String: 199 SSeq.field_str(SSeq.get_or(slot, SSeq.accounts_of(seq), Json.JNull{}), "uuid") 200 201 def resolved_kind4(+is_cur: Bool, +n: String, +seq: Json.Json, +live: String, +others: List<&2, Other>, +uuid: String) -> CKind: 202 match is_cur: 203 case True{}: 204 KOwnFamily{} 205 case False{}: 206 sel_ck(SBase.nonempty(uuid) && String.eq(String.trim(stored_uuid(seq, n)), uuid), foreign_or_synced(n, live, others), KAlien{}) 207 208 def resolved_kind3(+own: Bool, +slot: Maybe<&2, String>, +seq: Json.Json, +cur: String, +live: String, +others: List<&2, Other>, +uuid: String, +email: String, +has_org: Bool) -> CKind: 209 match own slot: 210 case True{} _: 211 KOwnFamily{} 212 case False{} None{}: 213 sel_ck(SBase.nonempty(email) && has_org, KAlien{}, KUnresolved{}) 214 case False{} Some{n}: 215 resolved_kind4(String.eq(n, cur), n, seq, live, others, uuid) 216 217 # An email match whose stored uuid disagrees is not a match. 218 def drop_mismatch(+m: Maybe<&2, String>, +seq: Json.Json, +uuid: String) -> Maybe<&2, String>: 219 match m: 220 case Some{n}: 221 B.sel_ms(SBase.nonempty(uuid) && SBase.nonempty(stored_uuid(seq, n)) && Bool.not(String.eq(stored_uuid(seq, n), uuid)), None{}, Some{n}) 222 case None{}: 223 None{} 224 225 # The slot holding (uuid, org), if any. 226 def uuid_in(+fields: Json.Json, +uuid: String, +org: String) -> Maybe<&2, String>: 227 match fields: 228 case Json.JField{k, v, t}: 229 SSeq.pick_slot(SBase.nonempty(SSeq.field_str(v, "uuid")) && String.eq(SSeq.field_str(v, "uuid"), uuid) && String.eq(SSeq.field_str(v, "organizationUuid"), org), k, uuid_in(t, uuid, org)) 230 case _: 231 None{} 232 233 def resolved_slot3(+m: Maybe<&2, String>, +seq: Json.Json, +uuid: String, +org: String) -> Maybe<&2, String>: 234 match m: 235 case Some{n}: 236 Some{n} 237 case None{}: 238 B.sel_ms(SBase.nonempty(uuid), uuid_in(SSeq.fields_of(seq), uuid, org), None{}) 239 240 def resolved_slot2(+by_email: Maybe<&2, String>, +seq: Json.Json, +uuid: String, +org: String) -> Maybe<&2, String>: 241 resolved_slot3(drop_mismatch(by_email, seq, uuid), seq, uuid, org) 242 243 def resolved_slot(+seq: Json.Json, +email: String, +org: String, +uuid: String) -> Maybe<&2, String>: 244 resolved_slot2(B.sel_ms(SBase.nonempty(email), SSeq.find_slot(seq, email, org), None{}), seq, uuid, org) 245 246 def stored_org(+seq: Json.Json, +slot: String) -> String: 247 SSeq.field_str(SSeq.get_or(slot, SSeq.accounts_of(seq), Json.JNull{}), "organizationUuid") 248 249 def resolved_kind2(+seq: Json.Json, +cur: String, +live: String, +others: List<&2, Other>, +uuid: String, +email: String, +org: String, +has_org: Bool) -> CKind: 250 resolved_kind3(SBase.nonempty(uuid) && String.eq(uuid, String.trim(stored_uuid(seq, cur))) && org_compatible(org, stored_org(seq, cur)), 251 resolved_slot(seq, email, org, uuid), seq, cur, live, others, uuid, email, has_org) 252 253 def resolved_kind(+seq: Json.Json, +cur: String, +live: String, +others: List<&2, Other>, +p: SPlan.Prof) -> CKind: 254 match p: 255 case SPlan.ProfNone{}: 256 KUnresolved{} 257 case SPlan.Prof{uuid, email, org}: 258 resolved_kind2(seq, cur, live, others, String.trim(uuid), SSeq.str_or(email, ""), SSeq.str_or(org, ""), B.is_some_str(org)) 259 260 def final_kind(+base: CKind, +seq: Json.Json, +cur: String, +live: String, +others: List<&2, Other>, +p: SPlan.Prof) -> CKind: 261 match base: 262 case KUnresolved{}: 263 resolved_kind(seq, cur, live, others, p) 264 case _: 265 base 266 267 # Alien and foreign live credentials are kept aside, never written into a slot. 268 def preserve(+k: CKind) -> Bool: 269 match k: 270 case KAlien{}: 271 True{} 272 case KForeign{s}: 273 True{} 274 case _: 275 False{}