swcreds.bend
cswap-bend-cli@0.1.0.0 · 275 lines · 44 definitions
1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/swbase.bend as B
4import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
5import cswap-bend-cli@0.1.0.0/store/plan.bend as SPlan
6import cswap-bend-cli@0.1.0.0/store/seq.bend as SSeq
7
8
9
10
11
12type CKind is Data:
13 KOwnBytes{}
14 KOwnFamily{}
15 KWiped{}
16 KForeignSynced{slot: String}
17 KForeign{slot: String}
18 KAlien{}
19 KUnresolved{}
20
21
22type Other is Data:
23 Other{slot: String, creds: String}
24
25def 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
28def 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
36def 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
43def 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
50def 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
57def 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
64def 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
71def 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
79
80
81def prepare_activation(+target: String, +live: String) -> String:
82 merge1(Json.parse(live), Json.parse(target), target)
83
84def oauth_of(+creds: Json.Json) -> Json.Json:
85 SSeq.get_or("claudeAiOauth", creds, Json.JNull{})
86
87def 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
94def refresh_of(+text: String) -> String:
95 refresh_of2(Json.parse(text))
96
97def 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
104def access_of(+text: String) -> String:
105 access_of2(Json.parse(text))
106
107def is_object(+j: Json.Json) -> Bool:
108 match j:
109 case Json.JObj{f}:
110 True{}
111 case _:
112 False{}
113
114def 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
121def is_oauth_obj(+text: String) -> Bool:
122 is_oauth_obj2(Json.parse(text))
123
124def 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
128
129def same_lineage(+a: String, +b: String) -> Bool:
130 same_lineage2(refresh_of(a), refresh_of(b), a, b)
131
132
133def wiped(+text: String) -> Bool:
134 is_oauth_obj(text) && String.is_empty(access_of(text)) && String.is_empty(refresh_of(text))
135
136def 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
143def classify3(+foreign: Maybe<&2, String>) -> CKind:
144 match foreign:
145 case Some{s}:
146 KForeignSynced{s}
147 case None{}:
148 KUnresolved{}
149
150def 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
157def 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
167
168
169def 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
172def 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
185def 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
188def sel_ck(+c: Bool, +x: CKind, +y: CKind) -> CKind:
189 match c:
190 case True{}:
191 x
192 case False{}:
193 y
194
195def 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
198def stored_uuid(+seq: Json.Json, +slot: String) -> String:
199 SSeq.field_str(SSeq.get_or(slot, SSeq.accounts_of(seq), Json.JNull{}), "uuid")
200
201def 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
208def 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
218def 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
226def 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
233def 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
240def 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
243def 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
246def stored_org(+seq: Json.Json, +slot: String) -> String:
247 SSeq.field_str(SSeq.get_or(slot, SSeq.accounts_of(seq), Json.JNull{}), "organizationUuid")
248
249def 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
253def 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
260def 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
268def preserve(+k: CKind) -> Bool:
269 match k:
270 case KAlien{}:
271 True{}
272 case KForeign{s}:
273 True{}
274 case _:
275 False{}