store/ops.bend

cswap-bend-cli@0.1.0.0 · 293 lines · 49 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/act.bend as Act
4import cswap-bend-cli@0.1.0.0/lock.bend as Lock
5import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
6import cswap-bend-cli@0.1.0.0/store/seq.bend as SSeq
7import cswap-bend-cli@0.1.0.0/store/plan.bend as SPlan
8import cswap-bend-cli@0.1.0.0/store/io.bend as SIo
9
10# `do ActM<A>:` needs the monad defined in the file that uses it.
11def ActM(-A: Type) -> Type:
12  Act.of(A)
13
14def ActM.pure(-A: Type, x: A) -> Act.of(A):
15  Act.pure(A, x)
16
17def ActM.bind(-A: Type, -B: Type, m: Act.of(A), f: A -> Act.of(B)) -> Act.of(B):
18  Act.bind(A, B, m, f)
19
20# ---- add-token ------------------------------------------------------------------------------
21
22# The email and slot add-token ends up with. Without an email, the slot is
23# fixed first so the synthesized address is unique.
24type Eff is Data:
25  Eff{email: String, slot: Maybe<&2, U32>}
26
27def eff_slot(+api_key: Bool, +n: U32) -> Eff:
28  Eff{SPlan.default_email(api_key, n), Some{n}}
29
30def eff_noemail(+api_key: Bool, +slot: Maybe<&2, U32>, +seq: Json.Json) -> Eff:
31  eff_slot(api_key, Maybe.default(&2, U32, slot, SSeq.next_slot(seq)))
32
33def effective(+email0: Maybe<&2, String>, +slot0: Maybe<&2, U32>, +seq: Json.Json, +api_key: Bool) -> Eff:
34  match email0:
35    case Some{e}:
36      Eff{e, slot0}
37    case None{}:
38      eff_noemail(api_key, slot0, seq)
39
40def email_ok(+m: Maybe<&2, String>) -> Bool:
41  match m:
42    case None{}:
43      True{}
44    case Some{e}:
45      SBase.valid_email(e)
46
47def is_none(+m: Maybe<&2, String>) -> Bool:
48  match m:
49    case None{}:
50      True{}
51    case Some{x}:
52      False{}
53
54def clash3(+existing_api: Bool, +n: String, +email: String, +api_key: Bool) -> Maybe<&2, String>:
55  match existing_api api_key:
56    case True{} True{}:
57      None{}
58    case False{} False{}:
59      None{}
60    case _ _:
61      Some{"'" ++ email ++ "' already exists as an " ++ SPlan.kind_title(existing_api) ++ " account (slot " ++ n ++ "); cannot add it as an "
62        ++ SPlan.kind_title(api_key) ++ " account. Pass a distinct --email."}
63
64def clash2(+slot: Maybe<&2, String>, +seq: Json.Json, +email: String, +api_key: Bool) -> Maybe<&2, String>:
65  match slot:
66    case None{}:
67      None{}
68    case Some{n}:
69      clash3(SSeq.is_api_key_rec(SSeq.get_or(n, SSeq.accounts_of(seq), Json.JNull{})), n, email, api_key)
70
71# (email, personal) already stored as the other kind.
72def clash(+seq: Json.Json, +email: String, +api_key: Bool) -> Maybe<&2, String>:
73  clash2(SSeq.find_slot(seq, email, ""), seq, email, api_key)
74
75def clash_msg(+m: Maybe<&2, String>) -> String:
76  SSeq.str_or(m, "")
77
78def refresh_slot(+slot: Maybe<&2, U32>, +seq: Json.Json, +email: String) -> Maybe<&2, String>:
79  match slot:
80    case Some{n}:
81      None{}
82    case None{}:
83      SSeq.find_slot(seq, email, "")
84
85def moved_line(+m: Maybe<&2, String>, +from: String, +to: String) -> String:
86  match m:
87    case None{}:
88      ""
89    case Some{old}:
90      "Moved from slot " ++ old ++ " → " ++ to ++ "\n"
91
92def unverified_reason(+api_key: Bool) -> String:
93  SBase.sel.s(api_key, "an API key has no OAuth profile to look up", "a setup-token has only the user:inference scope, so GET /api/oauth/profile answers 403")
94
95def token_files(+root: String, +token: String, +api_key: Bool, +email: String, +num: String) -> Act.of(Unit):
96  do ActM<Unit>:
97    SIo.write_cred(root, num, email, SBase.sel.s(api_key, token, SPlan.setup_token_creds(token)))
98    SIo.write_config(root, num, email, SPlan.token_config(email))
99
100def token_logs(+root: String, +info: String, +api_key: Bool, +email: String, +num: String) -> Act.of(Unit):
101  do ActM<Unit>:
102    SIo.log(root, "INFO", info)
103    SIo.log(root, "WARNING", "identity unverified for slot " ++ num ++ " (" ++ email ++ "): " ++ unverified_reason(api_key) ++ "; recorded as unverified")
104
105def token_seq(+s2: Json.Json, +num: String, +email: String, +ts: String, +api_key: Bool) -> Json.Json:
106  SSeq.stamp(SSeq.put_account(s2, num, SPlan.token_record(email, ts, api_key)), ts)
107
108def token_place2(+root: String, +token: String, +api_key: Bool, +email: String, +ts: String, +seq: Json.Json, +num: String, +displace: Maybe<&2, String>, +migrate: Maybe<&2, String>) -> Act.of(String):
109  do ActM<String>:
110    s2 : Json.Json <- SIo.drop_pair(root, seq, displace, migrate, ts)
111    token_files(root, token, api_key, email, num)
112    SIo.write_seq(root, token_seq(s2, num, email, ts, api_key))
113    token_logs(root, "Added account " ++ num ++ " from " ++ SPlan.kind_label(api_key) ++ ": " ++ email, api_key, email, num)
114    return moved_line(migrate, "", num) ++ "Added Account " ++ num ++ ": " ++ email ++ " [personal] (from " ++ SPlan.kind_label(api_key) ++ ")"
115
116def token_place(+root: String, +token: String, +api_key: Bool, +email: String, +ts: String, +seq: Json.Json, +p: SPlan.SlotPlan, +migrate_slot: String) -> Act.of(String):
117  match p:
118    case SPlan.SPErr{code, msg}:
119      Act.fail(String, code, msg)
120    case SPlan.SPOk{num, displace, migrate}:
121      token_place2(root, token, api_key, email, ts, seq, num, displace, migrate)
122
123def token_refresh(+root: String, +token: String, +api_key: Bool, +email: String, +ts: String, +seq: Json.Json, +num: String) -> Act.of(String):
124  do ActM<String>:
125    token_files(root, token, api_key, email, num)
126    SIo.write_seq(root, SSeq.stamp(seq, ts))
127    token_logs(root, "Updated " ++ SPlan.kind_label(api_key) ++ " for account " ++ num ++ ": " ++ email, api_key, email, num)
128    return "Updated " ++ SPlan.kind_label(api_key) ++ " for Account " ++ num ++ " (" ++ email ++ " [personal])."
129
130def token_branch2(+refresh: Maybe<&2, String>, +root: String, +token: String, +api_key: Bool, +email: String, +slot: Maybe<&2, U32>, +assume_yes: Bool, +ts: String, +seq: Json.Json) -> Act.of(String):
131  match refresh:
132    case Some{num}:
133      token_refresh(root, token, api_key, email, ts, seq, num)
134    case None{}:
135      token_place(root, token, api_key, email, ts, seq, SPlan.plan_slot(slot, seq, email, "", assume_yes), "")
136
137def token_branch(+root: String, +token: String, +api_key: Bool, +e: Eff, +assume_yes: Bool, +ts: String, +seq: Json.Json) -> Act.of(String):
138  match e:
139    case Eff{+email, +slot}:
140      token_branch2(refresh_slot(slot, seq, email), root, token, api_key, email, slot, assume_yes, ts, seq)
141
142def token_checked2(+c: Maybe<&2, String>, +e: Eff, +root: String, +token: String, +api_key: Bool, +assume_yes: Bool, +ts: String, +seq: Json.Json) -> Act.of(String):
143  do ActM<String>:
144    Act.guard(is_none(c), 2, clash_msg(c))
145    token_branch(root, token, api_key, e, assume_yes, ts, seq)
146
147def token_checked(+e: Eff, +root: String, +token: String, +api_key: Bool, +assume_yes: Bool, +ts: String, +seq: Json.Json) -> Act.of(String):
148  match e:
149    case Eff{+email, slot}:
150      token_checked2(clash(seq, email, api_key), Eff{email, slot}, root, token, api_key, assume_yes, ts, seq)
151
152def add_token4(+root: String, +token: String, +email0: Maybe<&2, String>, +slot0: Maybe<&2, U32>, +assume_yes: Bool, +ts: String, +seq: Json.Json) -> Act.of(String):
153  token_checked(effective(email0, slot0, seq, SBase.looks_like_api_key(token)), root, token, SBase.looks_like_api_key(token), assume_yes, ts, seq)
154
155def add_token3(+root: String, +token: String, +email0: Maybe<&2, String>, +slot0: Maybe<&2, U32>, +assume_yes: Bool) -> Act.of(String):
156  do ActM<String>:
157    SIo.setup_dirs(root)
158    +ts : String <- SIo.now_iso()
159    seq : Json.Json <- SIo.ensure_seq(root, ts)
160    add_token4(root, token, email0, slot0, assume_yes, ts, seq)
161
162def add_token2(+root: String, +token: String, +email0: Maybe<&2, String>, +slot0: Maybe<&2, U32>, +assume_yes: Bool) -> Act.of(String):
163  do ActM<String>:
164    Act.guard(SBase.nonempty(token), 2, "Token cannot be empty")
165    Act.guard(email_ok(email0), 2, "Invalid email format: " ++ SSeq.str_or(email0, ""))
166    Lock.with_lock(String, SBase.lock_path(root), 10000, add_token3(root, token, email0, slot0, assume_yes))
167
168# Register a raw setup-token or `sk-ant-` API key (no HTTP call). `assume_yes`
169# stands for the "Overwrite slot N?" answer; without it an occupied slot is
170# Fail{(10, ...)} for the CLI to confirm and retry. The record is written with
171# an empty identity and logged as unverified: a setup-token has no profile
172# scope, so the oracle cannot resolve it.
173def add_token(+ctx: SBase.Ctx, +token: String, +email0: Maybe<&2, String>, +slot0: Maybe<&2, U32>, +assume_yes: Bool) -> Act.of(String):
174  add_token2(SBase.root_of(ctx), String.trim(token), email0, slot0, assume_yes)
175
176# ---- remove, read ---------------------------------------------------------------------------
177
178def is_some_str(+m: Maybe<&2, String>) -> Bool:
179  Bool.not(is_none(m))
180
181def valid_ident(+seq: Json.Json, +ident: String) -> Bool:
182  SBase.all_digits(ident) || SBase.valid_email(ident) || is_some_str(SSeq.find_alias(seq, ident))
183
184def scalar_text(+m: Maybe<&2, Json.Json>) -> String:
185  match m:
186    case Some{Json.JNum{t}}:
187      t
188    case Some{Json.JStr{s}}:
189      s
190    case _:
191      ""
192
193# A JSON scalar as text: numbers as written, strings as is, "" otherwise.
194def field_json(+rec: Json.Json, +key: String) -> String:
195  scalar_text(Json.get(key, rec))
196
197def active_is(+seq: Json.Json, +num: String) -> Bool:
198  String.eq(field_json(seq, "activeAccountNumber"), num)
199
200def active_warning(+seq: Json.Json, +num: String, +email: String) -> String:
201  SBase.sel.s(active_is(seq, num), "Warning: Account-" ++ num ++ " (" ++ email ++ ") is currently active\n", "")
202
203def removal3(+root: String, +seq: Json.Json, +num: String, +ts: String, +email: String) -> Act.of(String):
204  do ActM<String>:
205    SIo.archive_slot(root, num, email, ts)
206    SIo.write_seq(root, SSeq.stamp(SSeq.drop_account(seq, num), ts))
207    SIo.log(root, "INFO", "Removed account " ++ num ++ ": " ++ email ++ "; credential kept under credentials/.removed/")
208    return active_warning(seq, num, email) ++ "Removed Account-" ++ num ++ " (" ++ email ++ ")"
209
210def removal2(+root: String, +seq: Json.Json, +num: String, +ts: String, +rec: Maybe<&2, Json.Json>) -> Act.of(String):
211  match rec:
212    case None{}:
213      Act.fail(String, 3, "Account-" ++ num ++ " does not exist")
214    case Some{r}:
215      removal3(root, seq, num, ts, SSeq.field_str(r, "email"))
216
217def removal(+root: String, +seq: Json.Json, +num: String, +ts: String) -> Act.of(String):
218  removal2(root, seq, num, ts, SSeq.slot_record(seq, num))
219
220def remove_resolved(+root: String, +seq: Json.Json, +ident: String, +ts: String, +r: SSeq.Resolved) -> Act.of(String):
221  match r:
222    case SSeq.RNone{}:
223      Act.fail(String, 3, "No account found with identifier: " ++ ident)
224    case SSeq.RAmb{msg}:
225      Act.fail(String, 1, msg)
226    case SSeq.RSlot{n}:
227      removal(root, seq, n, ts)
228
229def remove3(+root: String, +ident: String, +ts: String, +seq: Json.Json) -> Act.of(String):
230  do ActM<String>:
231    Act.guard(valid_ident(seq, ident), 2, "Invalid account identifier: " ++ ident)
232    remove_resolved(root, seq, ident, ts, SSeq.resolve(seq, ident))
233
234def remove4(+root: String, +ident: String, +m: Maybe<&2, Json.Json>) -> Act.of(String):
235  match m:
236    case None{}:
237      Act.fail(String, 1, "No accounts are managed yet")
238    case Some{seq}:
239      do ActM<String>:
240        ts : String <- SIo.now_iso()
241        remove3(root, ident, SBase.compact_ts(ts), seq)
242
243def remove2(+root: String, +ident: String) -> Act.of(String):
244  do ActM<String>:
245    SIo.setup_dirs(root)
246    m : Maybe<&2, Json.Json> <- SIo.read_seq(root)
247    remove4(root, ident, m)
248
249# Take an account out of rotation by slot number, alias or email. Its
250# credential is moved to credentials/.removed/<utc-ts>-<slot>-<email>.enc
251# (0600), never deleted. The confirmation prompt belongs to the CLI. When the
252# account is the active one the answer starts with the reference's warning
253# line. Several accounts sharing an email are an error here (the reference
254# asks interactively).
255def remove(+ctx: SBase.Ctx, +ident: String) -> Act.of(String):
256  do ActM<String>:
257    Lock.with_lock(String, SBase.lock_path(SBase.root_of(ctx)), 10000, remove2(SBase.root_of(ctx), ident))
258
259def read_resolved(+root: String, +r: SSeq.Resolved, +seq: Json.Json, +ident: String) -> Act.of(String):
260  match r:
261    case SSeq.RNone{}:
262      Act.fail(String, 3, "No account found with identifier: " ++ ident)
263    case SSeq.RAmb{msg}:
264      Act.fail(String, 1, msg)
265    case SSeq.RSlot{+n}:
266      SIo.read_cred(root, n, SIo.slot_email(seq, n))
267
268def read_slot2(+root: String, +ident: String, +m: Maybe<&2, Json.Json>) -> Act.of(String):
269  match m:
270    case None{}:
271      Act.fail(String, 1, "No accounts are managed yet")
272    case Some{seq}:
273      read_resolved(root, SSeq.resolve(seq, ident), seq, ident)
274
275# A slot's stored credential text by slot number, alias or email; "" when the
276# slot has none.
277def read_slot(+ctx: SBase.Ctx, +ident: String) -> Act.of(String):
278  do ActM<String>:
279    m : Maybe<&2, Json.Json> <- SIo.read_seq(SBase.root_of(ctx))
280    read_slot2(SBase.root_of(ctx), ident, m)
281
282def write_slot2(+root: String, +creds: String, +num: String, +m: Maybe<&2, Json.Json>) -> Act.of(Unit):
283  match m:
284    case None{}:
285      Act.fail(Unit, 1, "No accounts are managed yet")
286    case Some{seq}:
287      SIo.write_cred(root, num, SIo.slot_email(seq, num), creds)
288
289# Replace the credential of an existing slot (keeping the old one as .prev).
290def write_slot(+ctx: SBase.Ctx, +num: String, +creds: String) -> Act.of(Unit):
291  do ActM<Unit>:
292    m : Maybe<&2, Json.Json> <- SIo.read_seq(SBase.root_of(ctx))
293    write_slot2(SBase.root_of(ctx), creds, num, m)