1 import Base 2 import ../json.bend as Json 3 import ../fs.bend as Fs 4 import ../fs/write.bend as FsW 5 import ../act.bend as Act 6 import ../lock.bend as Lock 7 import ../store.bend as Store 8 import ../store/base.bend as SBase 9 import ../store/seq.bend as SSeq 10 11 # autoswitch_state.json (contract 1.7): the last switch, the account it left, 12 # and the quarantine of accounts whose refresh token is dead. Read lock-free, 13 # written read-modify-write under /.autoswitch_state.lock, unknown keys 14 # kept. `--dry-run` never writes it. 15 16 def ActM(-A: Type) -> Type: 17 Act.of(A) 18 19 def ActM.pure(-A: Type, x: A) -> Act.of(A): 20 Act.pure(A, x) 21 22 def ActM.bind(-A: Type, -B: Type, m: Act.of(A), f: A -> Act.of(B)) -> Act.of(B): 23 Act.bind(A, B, m, f) 24 25 def state_path(+root: String) -> String: 26 root ++ "/autoswitch_state.json" 27 28 def lock_path(+root: String) -> String: 29 root ++ "/.autoswitch_state.lock" 30 31 def empty() -> Json.Json: 32 Json.JObj{Json.JNil{}} 33 34 def obj_or_empty(+j: Json.Json) -> Json.Json: 35 match j: 36 case Json.JObj{f}: 37 j 38 case _: 39 empty() 40 41 def parsed(r: Result<&2, &2, String, Json.Json>) -> Json.Json: 42 match r: 43 case Done{j}: 44 obj_or_empty(j) 45 case Fail{e}: 46 empty() 47 48 def read2(r: Fs.Res(String)) -> Json.Json: 49 match r: 50 case Done{t}: 51 parsed(Json.parse(t)) 52 case Fail{e}: 53 empty() 54 55 # The state object; {} when the file is missing, torn or not an object. 56 def read_state(+root: String) -> IO(Json.Json): 57 IO.bind(Fs.Res(String), Json.Json, Fs.read_file(state_path(root)), r => IO.pure(Json.Json, read2(r))) 58 59 def write_state(+root: String, +j: Json.Json) -> Act.of(Unit): 60 FsW.write_atomic(state_path(root), Json.emit_pretty(j), "600") 61 62 # Hold the state lock around `act`. 63 def with_state_lock(-A: Type, +root: String, act: Act.of(A)) -> Act.of(A): 64 Lock.with_lock(A, lock_path(root), 10000, act) 65 66 # ---- the last switch -------------------------------------------------------------------------- 67 68 def fl(+t: U32) -> Json.Json: 69 Json.JNum{U32.show(t) ++ ".0"} 70 71 def opt(+m: Maybe<&2, U32>, f: U32 -> Json.Json) -> Json.Json: 72 match m: 73 case None{}: 74 Json.JNull{} 75 case Some{v}: 76 f(v) 77 78 def two(+n: U32) -> String: 79 SBase.sel.s(U32.is_lt(n, 10), "0" ++ U32.show(n), U32.show(n)) 80 81 def frac(+c: U32) -> String: 82 SBase.sel.s(U32.is_eq((c % 10 : U32), 0), U32.show((c / 10 : U32)), two(c)) 83 84 def cents_json(+c: U32) -> Json.Json: 85 Json.JNum{U32.show((c / 100 : U32)) ++ "." ++ frac((c % 100 : U32))} 86 87 def int_json(+n: U32) -> Json.Json: 88 Json.JNum{U32.show(n)} 89 90 # What `_perform` records after a switch. `left_headroom` is centi-percent, 91 # `left_recovery` unix seconds (None for the reference's inf). 92 def record_switch(+st: Json.Json, +now: U32, +to: String, +from: Maybe<&2, U32>, +left_headroom: Maybe<&2, U32>, +left_recovery: Maybe<&2, U32>, +trigger: String) -> Json.Json: 93 Json.set("leftTrigger", Json.JStr{trigger}, 94 Json.set("leftRecoveryAt", opt(left_recovery, u => fl(u)), 95 Json.set("leftHeadroom", opt(left_headroom, h => cents_json(h)), 96 Json.set("lastSwitchFrom", opt(from, n => int_json(n)), 97 Json.set("lastSwitchTo", Json.JStr{to}, 98 Json.set("lastSwitchAt", fl(now), 99 Json.set("schemaVersion", Json.JNum{"1"}, obj_or_empty(st)))))))) 100 101 # ---- quarantine ------------------------------------------------------------------------------ 102 103 def quarantine_of(+st: Json.Json) -> Json.Json: 104 obj_or_empty(SSeq.get_or("quarantine", st, empty())) 105 106 def opt_text(+m: Maybe<&2, String>) -> Json.Json: 107 match m: 108 case None{}: 109 Json.JNull{} 110 case Some{s}: 111 Json.JStr{s} 112 113 def entry(+email: String, +reason: String, +at: String, +fp: Maybe<&2, String>) -> Json.Json: 114 Json.JObj{Json.JField{"email", Json.JStr{email}, Json.JField{"reason", Json.JStr{reason}, Json.JField{"at", Json.JStr{at}, 115 Json.JField{"refreshTokenFingerprint", opt_text(fp), Json.JNil{}}}}}} 116 117 def quarantine(+st: Json.Json, +slot: String, +email: String, +reason: String, +at: String, +fp: Maybe<&2, String>) -> Json.Json: 118 Json.set("quarantine", Json.set(slot, entry(email, reason, at, fp), quarantine_of(st)), Json.set("schemaVersion", Json.JNum{"1"}, obj_or_empty(st))) 119 120 def release(+st: Json.Json, +slot: String) -> Json.Json: 121 Json.set("quarantine", Json.del(slot, quarantine_of(st)), Json.set("schemaVersion", Json.JNum{"1"}, obj_or_empty(st))) 122 123 def quarantined_slots(+st: Json.Json) -> List<&2, String>: 124 Json.keys(quarantine_of(st)) 125 126 def entry_email(+st: Json.Json, +slot: String) -> String: 127 SSeq.field_str(SSeq.get_or(slot, quarantine_of(st), Json.JNull{}), "email") 128 129 def entry_fp2(+m: Maybe<&2, Json.Json>) -> Maybe<&2, String>: 130 match m: 131 case Some{Json.JStr{s}}: 132 Some{s} 133 case _: 134 None{} 135 136 def entry_fp(+st: Json.Json, +slot: String) -> Maybe<&2, String>: 137 entry_fp2(Json.get("refreshTokenFingerprint", SSeq.get_or(slot, quarantine_of(st), Json.JNull{}))) 138 139 def first_part(+t: String) -> String: 140 Maybe.default(&2, String, List.head(&2, String, String.split(t, '.')), "") 141 142 def last2(+m: Maybe<&2, Json.Json>) -> Maybe<&2, U32>: 143 match m: 144 case Some{Json.JNum{t}}: 145 U32.read(first_part(t)) 146 case _: 147 None{} 148 149 # lastSwitchAt as seconds, None when absent or not a number. 150 def last_switch_at(+st: Json.Json) -> Maybe<&2, U32>: 151 last2(Json.get("lastSwitchAt", st)) 152 153 def cool2(+m: Maybe<&2, U32>, +now: U32, +cooldown: U32) -> Bool: 154 match m: 155 case Some{l}: 156 U32.is_lt((now - l : U32), cooldown) 157 case None{}: 158 False{} 159 160 # `now - lastSwitchAt < cooldown` 161 def in_cooldown(+st: Json.Json, +now: U32, +cooldown: U32) -> Bool: 162 cool2(last_switch_at(st), now, cooldown)