Skip to main content

bynk/registry/
account.rs

1//! `bynk login`, `bynk logout` and `bynk org` (first-party registry R3.1,
2//! #2378): the client side of the account API the hosted registry serves
3//! beside its publish API (R3, #2363).
4//!
5//! `login` runs GitHub's device flow through the registry and keeps the
6//! account token (`bynka_…`) in the credentials file's `[accounts.<name>]`,
7//! apart from the publish token `bynk publish` reads from
8//! `[registries.<name>] token`. `org token mint --save` writes a new publish
9//! token into the organisation's own slot,
10//! `[registries.<name>.organisations.<org>] token` (R3.2, #2383).
11
12use std::path::{Path, PathBuf};
13use std::time::{Duration, Instant};
14
15use bynk_project::lock::FIRST_PARTY_REGISTRY;
16use serde_json::{Value, json};
17
18use super::Client;
19use crate::fetch::{FetchError, fail};
20
21pub(crate) const ACCOUNT_REFUSED: &str = "bynk.registry.account_refused";
22
23/// RFC 8628 §3.5: a `slow_down` adds five seconds to the interval.
24const SLOW_DOWN: u64 = 5;
25
26/// The shortest wait between polls, whatever the server says: a server
27/// answering `interval: 0` can't make the client poll back to back.
28const MIN_INTERVAL: u64 = 1;
29
30/// Which registry a command talks to: `--registry`, else the one the
31/// organisation routes to in the project at `project` (`login` and `logout`
32/// have no organisation, and default to the first-party registry).
33#[derive(Debug, Clone)]
34pub struct Target {
35    pub project: PathBuf,
36    pub registry: Option<String>,
37}
38
39/// The account API, behind a seam the unit tests stub.
40pub(crate) trait Api {
41    /// `method` on `path` (relative to the account base), with a JSON body.
42    fn call(
43        &mut self,
44        method: &str,
45        path: &str,
46        body: Option<&Value>,
47    ) -> Result<(u16, Vec<u8>), FetchError>;
48}
49
50/// The live API: a registry opened through its verified `config.json`, and
51/// the account token, when there is one.
52struct Live {
53    client: Client,
54    registry: String,
55    bearer: Option<String>,
56}
57
58impl Api for Live {
59    fn call(
60        &mut self,
61        method: &str,
62        path: &str,
63        body: Option<&Value>,
64    ) -> Result<(u16, Vec<u8>), FetchError> {
65        self.client
66            .account_call(&self.registry, method, path, body, self.bearer.as_deref())
67            .map_err(|mut e| {
68                // The first-party registry isn't live yet: name the way round.
69                if e.code == super::UNAVAILABLE && self.registry == FIRST_PARTY_REGISTRY {
70                    e.message
71                        .push_str(" — or pass `--registry <name>` to use a configured registry");
72                }
73                e
74            })
75    }
76}
77
78/// A client over the project's `[registries]`, and the registry the command
79/// talks to.
80fn open(target: &Target, org: Option<&str>) -> (Client, String) {
81    let root = bynk_project::lock::lock_root(&target.project);
82    let client = Client::new(super::commands::registries_of(&root), None);
83    let registry = match (&target.registry, org) {
84        (Some(r), _) => r.clone(),
85        (None, Some(o)) => client.route(o),
86        (None, None) => FIRST_PARTY_REGISTRY.to_string(),
87    };
88    (client, registry)
89}
90
91/// The live API for `org`'s registry, signed in.
92fn signed_in(target: &Target, org: &str) -> Result<Live, FetchError> {
93    let (client, registry) = open(target, Some(org));
94    let session = session(&registry).ok_or_else(|| {
95        fail(
96            ACCOUNT_REFUSED,
97            format!(
98                "you aren't signed in to registry `{registry}`: run `bynk login --registry {registry}`"
99            ),
100        )
101    })?;
102    if session.expires_at.is_some_and(|t| t <= now_millis()) {
103        return Err(fail(
104            ACCOUNT_REFUSED,
105            format!(
106                "your session on registry `{registry}` expired: run `bynk login --registry {registry}`"
107            ),
108        ));
109    }
110    Ok(Live {
111        client,
112        registry,
113        bearer: Some(session.token),
114    })
115}
116
117// ---------------------------------------------------------------------------
118// Signing in and out
119// ---------------------------------------------------------------------------
120
121/// What `POST /account/login` answers.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub(crate) struct DeviceStart {
124    pub poll: String,
125    pub user_code: String,
126    pub verification_uri: String,
127    pub interval: u64,
128    pub expires_in: u64,
129}
130
131/// What a successful poll answers.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub(crate) struct SignedIn {
134    pub token: String,
135    pub login: String,
136    pub expires_at: i64,
137}
138
139/// Sign in to `target`'s registry through GitHub's device flow, and keep the
140/// account token.
141pub fn login(target: &Target) -> Result<(), FetchError> {
142    let (client, registry) = open(target, None);
143    let mut api = Live {
144        client,
145        registry: registry.clone(),
146        bearer: None,
147    };
148    let start = start(&mut api, &registry)?;
149    eprintln!(
150        "To sign in to registry `{registry}`, open {} and enter the code {}.\nNever enter a code you didn't request. Waiting…",
151        clean(&start.verification_uri),
152        clean(&start.user_code)
153    );
154    let began = Instant::now();
155    let signed = poll(&mut api, &registry, &start, &mut |d| {
156        std::thread::sleep(d);
157        began.elapsed()
158    })?;
159    let path = credentials_file()?;
160    let mut doc = read_doc(&path).map_err(|e| fail(ACCOUNT_REFUSED, e))?;
161    set_session(&mut doc, &registry, &signed);
162    write_doc(&path, &doc).map_err(|e| fail(ACCOUNT_REFUSED, e))?;
163    eprintln!(
164        "Signed in to `{registry}` as {} (until {}). Saved to {}.",
165        signed.login,
166        date(signed.expires_at),
167        path.display()
168    );
169    if std::env::var_os(override_variable(&registry)).is_some() {
170        eprintln!(
171            "bynk: warning: `{}` is set, and is used instead of the saved session",
172            override_variable(&registry)
173        );
174    }
175    Ok(())
176}
177
178/// Sign out of `target`'s registry: end the session on the server, and forget
179/// it here.
180pub fn logout(target: &Target) -> Result<(), FetchError> {
181    let (client, registry) = open(target, None);
182    let Some(session) = session(&registry) else {
183        eprintln!("bynk: you aren't signed in to registry `{registry}`");
184        return Ok(());
185    };
186    let mut api = Live {
187        client,
188        registry: registry.clone(),
189        bearer: Some(session.token),
190    };
191    let (status, body) = api.call("DELETE", "session", None)?;
192    // A `401` means the session is already over: forget it all the same.
193    if !(200..300).contains(&status) && status != 401 {
194        return Err(refused(&registry, status, &body));
195    }
196    if let Some(path) = super::credentials_path() {
197        let mut doc = read_doc(&path).map_err(|e| fail(ACCOUNT_REFUSED, e))?;
198        if remove_session(&mut doc, &registry) {
199            write_doc(&path, &doc).map_err(|e| fail(ACCOUNT_REFUSED, e))?;
200        }
201    }
202    eprintln!("bynk: signed out of `{registry}`");
203    if session.from_env {
204        eprintln!(
205            "bynk: note: `{}` still holds the token; unset it",
206            override_variable(&registry)
207        );
208    }
209    Ok(())
210}
211
212/// `POST /account/login`.
213pub(crate) fn start(api: &mut dyn Api, registry: &str) -> Result<DeviceStart, FetchError> {
214    let (status, body) = api.call("POST", "login", None)?;
215    if status != 200 {
216        return Err(refused(registry, status, &body));
217    }
218    let v = parse(registry, &body)?;
219    let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned);
220    let num = |k: &str| v.get(k).and_then(Value::as_u64);
221    match (
222        text("poll"),
223        text("userCode"),
224        text("verificationUri"),
225        num("interval"),
226        num("expiresIn"),
227    ) {
228        (Some(poll), Some(user_code), Some(verification_uri), Some(interval), Some(expires_in)) => {
229            Ok(DeviceStart {
230                poll,
231                user_code,
232                verification_uri,
233                interval,
234                expires_in,
235            })
236        }
237        _ => Err(malformed(registry)),
238    }
239}
240
241/// Poll until the person approves, declines, or the code expires (decision
242/// C). `wait` sleeps for the interval and answers the time since the flow
243/// started; the client stops at `expiresIn` whatever the server says.
244pub(crate) fn poll(
245    api: &mut dyn Api,
246    registry: &str,
247    start: &DeviceStart,
248    wait: &mut dyn FnMut(Duration) -> Duration,
249) -> Result<SignedIn, FetchError> {
250    let mut interval = start.interval.max(MIN_INTERVAL);
251    let deadline = Duration::from_secs(start.expires_in);
252    let expired = || {
253        fail(
254            ACCOUNT_REFUSED,
255            "the code expired before it was approved: run `bynk login` again",
256        )
257    };
258    loop {
259        if wait(Duration::from_secs(interval)) >= deadline {
260            return Err(expired());
261        }
262        let (status, body) =
263            api.call("POST", "login/poll", Some(&json!({ "poll": start.poll })))?;
264        if status == 200 {
265            let v = parse(registry, &body)?;
266            return match (
267                v.get("token").and_then(Value::as_str),
268                v.get("login").and_then(Value::as_str),
269                v.get("expiresAt").and_then(Value::as_i64),
270            ) {
271                (Some(token), Some(login), Some(expires_at)) => Ok(SignedIn {
272                    token: token.to_owned(),
273                    login: login.to_owned(),
274                    expires_at,
275                }),
276                _ => Err(malformed(registry)),
277            };
278        }
279        // The `error` code decides, not the status: `400` is also a bad
280        // request, and `403` every permission refusal.
281        match error_code(&body).as_deref() {
282            Some("authorization_pending") => {}
283            Some("slow_down") => interval += SLOW_DOWN,
284            Some("expired_token") => return Err(expired()),
285            Some("access_denied") => {
286                return Err(fail(ACCOUNT_REFUSED, "the sign-in was declined at GitHub"));
287            }
288            _ => return Err(refused(registry, status, &body)),
289        }
290    }
291}
292
293// ---------------------------------------------------------------------------
294// Organisations, members and tokens
295// ---------------------------------------------------------------------------
296
297/// Claim `org`, first come (`block` holds it without publishing; an operator
298/// names the `owner`).
299pub fn claim(
300    target: &Target,
301    org: &str,
302    block: bool,
303    owner: Option<&str>,
304) -> Result<(), FetchError> {
305    let mut api = signed_in(target, org)?;
306    let mut body = json!({ "block": block });
307    if let Some(o) = owner {
308        body["owner"] = json!(o);
309    }
310    let (status, answer) = api.call("POST", &orgs(&[org])?, Some(&body))?;
311    let registry = &api.registry;
312    match status {
313        201 => eprintln!(
314            "bynk: claimed `{org}` on `{registry}`{}",
315            if block { " (blocked)" } else { "" }
316        ),
317        200 => eprintln!("bynk: `{org}` on `{registry}` is already yours"),
318        _ => return Err(refused(registry, status, &answer)),
319    }
320    Ok(())
321}
322
323/// Print `org`: its owner, members, whether it is blocked, and its tokens
324/// (never the tokens themselves). `json` prints the API's answer as it is.
325pub fn show(target: &Target, org: &str, json: bool) -> Result<(), FetchError> {
326    let mut api = signed_in(target, org)?;
327    let view = view(&mut api, org)?;
328    if json {
329        println!(
330            "{}",
331            serde_json::to_string_pretty(&view).expect("a JSON value")
332        );
333    } else {
334        print!("{}", render(&api.registry, &view));
335    }
336    Ok(())
337}
338
339/// Block or unblock `org`.
340pub fn set_blocked(target: &Target, org: &str, blocked: bool) -> Result<(), FetchError> {
341    let mut api = signed_in(target, org)?;
342    acknowledged(
343        &mut api,
344        "PUT",
345        &orgs(&[org, "blocked"])?,
346        Some(&json!({ "blocked": blocked })),
347    )?;
348    eprintln!(
349        "bynk: {} `{org}` on `{}`",
350        if blocked { "blocked" } else { "unblocked" },
351        api.registry
352    );
353    Ok(())
354}
355
356/// Add the GitHub user `login` to `org`.
357pub fn member_add(target: &Target, org: &str, login: &str) -> Result<(), FetchError> {
358    let mut api = signed_in(target, org)?;
359    acknowledged(&mut api, "PUT", &orgs(&[org, "members", login])?, None)?;
360    eprintln!("bynk: added {login} to `{org}` on `{}`", api.registry);
361    Ok(())
362}
363
364/// Remove a member from `org`, by GitHub login or numeric id.
365pub fn member_remove(target: &Target, org: &str, who: &str) -> Result<(), FetchError> {
366    let mut api = signed_in(target, org)?;
367    let id = member_id(&mut api, org, who)?;
368    acknowledged(&mut api, "DELETE", &orgs(&[org, "members", &id])?, None)?;
369    eprintln!("bynk: removed {who} from `{org}` on `{}`", api.registry);
370    Ok(())
371}
372
373/// What `token mint` is asked for.
374#[derive(Debug, Clone)]
375pub struct Mint {
376    pub can: Vec<String>,
377    pub days: u32,
378    pub note: String,
379    /// Write it to `[registries.<name>.organisations.<org>] token` instead
380    /// of printing it.
381    pub save: bool,
382    /// Let `save` replace a different token.
383    pub force: bool,
384}
385
386/// Mint a publish token for `org`: printed once on stdout, or saved where
387/// `bynk publish` reads it (decision E).
388pub fn token_mint(target: &Target, org: &str, mint: &Mint) -> Result<(), FetchError> {
389    let mut api = signed_in(target, org)?;
390    let registry = api.registry.clone();
391    // Check the slot before minting, so a refused `--save` mints nothing.
392    let saving = if mint.save {
393        let path = credentials_file()?;
394        let doc = read_doc(&path).map_err(|e| fail(ACCOUNT_REFUSED, e))?;
395        check_publish_slot(&doc, &registry, org, None, mint.force)
396            .map_err(|e| fail(ACCOUNT_REFUSED, e))?;
397        Some((path, doc))
398    } else {
399        None
400    };
401    let body = json!({ "can": mint.can, "days": mint.days, "note": mint.note });
402    let (status, answer) = api.call("POST", &orgs(&[org, "tokens"])?, Some(&body))?;
403    if status != 201 && status != 200 {
404        return Err(refused(&registry, status, &answer));
405    }
406    let v = parse(&registry, &answer)?;
407    let token = v
408        .get("token")
409        .and_then(Value::as_str)
410        .ok_or_else(|| malformed(&registry))?;
411    let info = v.get("info").cloned().unwrap_or(Value::Null);
412    let id = info.get("id").and_then(Value::as_str).unwrap_or("?");
413    let can = info
414        .get("can")
415        .and_then(Value::as_array)
416        .map(|c| {
417            c.iter()
418                .filter_map(Value::as_str)
419                .collect::<Vec<_>>()
420                .join(", ")
421        })
422        .unwrap_or_default();
423    let until = info
424        .get("expiresAt")
425        .and_then(Value::as_i64)
426        .map(date)
427        .unwrap_or_default();
428    eprintln!(
429        "bynk: minted token {} for `{org}` on `{registry}` ({can}; until {until})",
430        short_id(id)
431    );
432    match saving {
433        Some((path, mut doc)) => {
434            check_publish_slot(&doc, &registry, org, Some(token), mint.force)
435                .map_err(|e| fail(ACCOUNT_REFUSED, e))?;
436            let saved = set_publish_token(&mut doc, &registry, org, token)
437                .and_then(|()| write_doc(&path, &doc));
438            if let Err(e) = saved {
439                // The token is live on the server: never lose it.
440                eprintln!(
441                    "bynk: couldn't save it, so it is shown once, below; keep it somewhere safe, or revoke it with `bynk org token revoke {org} {}`",
442                    short_id(id)
443                );
444                println!("{token}");
445                return Err(fail(ACCOUNT_REFUSED, e));
446            }
447            eprintln!(
448                "bynk: saved as `[registries.{registry}.organisations.{org}] token` in {}",
449                path.display()
450            );
451            if registry_wide_token(&doc, &registry) {
452                eprintln!(
453                    "bynk: `{org}` now uses its own token; `[registries.{registry}] token` still serves other organisations"
454                );
455            }
456            let var = bynk_project::manifest::registry_token_var(&registry);
457            if std::env::var_os(&var).is_some() {
458                eprintln!(
459                    "bynk: warning: `{var}` is set, and `bynk publish` uses it instead of any saved token"
460                );
461            }
462        }
463        None => {
464            eprintln!("bynk: it is shown once, below; keep it somewhere safe");
465            println!("{token}");
466        }
467    }
468    Ok(())
469}
470
471/// Revoke one of `org`'s publish tokens, by its id (or a unique prefix of it,
472/// as `org show` prints it).
473pub fn token_revoke(target: &Target, org: &str, id: &str) -> Result<(), FetchError> {
474    let mut api = signed_in(target, org)?;
475    let id = token_id(&mut api, org, id)?;
476    acknowledged(&mut api, "DELETE", &orgs(&[org, "tokens", &id])?, None)?;
477    eprintln!(
478        "bynk: revoked token {} for `{org}` on `{}`",
479        short_id(&id),
480        api.registry
481    );
482    Ok(())
483}
484
485/// `GET /account/orgs/<org>`.
486fn view(api: &mut Live, org: &str) -> Result<Value, FetchError> {
487    let (status, body) = api.call("GET", &orgs(&[org])?, None)?;
488    if status != 200 {
489        return Err(refused(&api.registry, status, &body));
490    }
491    parse(&api.registry, &body)
492}
493
494/// A change the API answers with `{"ok": true}`.
495fn acknowledged(
496    api: &mut Live,
497    method: &str,
498    path: &str,
499    body: Option<&Value>,
500) -> Result<(), FetchError> {
501    let (status, answer) = api.call(method, path, body)?;
502    if (200..300).contains(&status) {
503        Ok(())
504    } else {
505        Err(refused(&api.registry, status, &answer))
506    }
507}
508
509/// A member's GitHub id: `who` itself when it is one, else the id of the
510/// member whose login it is (the API removes members by id, since logins can
511/// be reassigned).
512fn member_id(api: &mut Live, org: &str, who: &str) -> Result<String, FetchError> {
513    if !who.is_empty() && who.bytes().all(|b| b.is_ascii_digit()) {
514        return Ok(who.to_owned());
515    }
516    let v = view(api, org)?;
517    find_member(&v, who)
518        .ok_or_else(|| fail(ACCOUNT_REFUSED, format!("`{org}` has no member `{who}`")))
519}
520
521/// The id of the member of `view` whose login is `login` (GitHub logins are
522/// case-insensitive).
523pub(crate) fn find_member(view: &Value, login: &str) -> Option<String> {
524    view.get("members")?
525        .as_array()?
526        .iter()
527        .find(|m| {
528            m.get("login")
529                .and_then(Value::as_str)
530                .is_some_and(|l| l.eq_ignore_ascii_case(login))
531        })
532        .and_then(|m| m.get("id")?.as_i64())
533        .map(|id| id.to_string())
534}
535
536/// A token's full id from `prefix`, which must name exactly one.
537fn token_id(api: &mut Live, org: &str, prefix: &str) -> Result<String, FetchError> {
538    let v = view(api, org)?;
539    find_token(&v, prefix).map_err(|why| fail(ACCOUNT_REFUSED, format!("`{org}` {why}")))
540}
541
542/// The one token id in `view` that starts with `prefix`.
543pub(crate) fn find_token(view: &Value, prefix: &str) -> Result<String, String> {
544    let ids: Vec<&str> = view
545        .get("tokens")
546        .and_then(Value::as_array)
547        .map(|ts| {
548            ts.iter()
549                .filter_map(|t| t.get("id")?.as_str())
550                .filter(|id| !prefix.is_empty() && id.starts_with(prefix))
551                .collect()
552        })
553        .unwrap_or_default();
554    match ids.as_slice() {
555        [one] => Ok((*one).to_owned()),
556        [] => Err(format!("has no token `{prefix}` (see `bynk org show`)")),
557        _ => Err(format!(
558            "has {} tokens starting `{prefix}`: give more of the id",
559            ids.len()
560        )),
561    }
562}
563
564/// `orgs/<segment>/…`, refusing a segment that isn't a plain name, so nothing
565/// typed can reach another path.
566fn orgs(segments: &[&str]) -> Result<String, FetchError> {
567    let mut path = String::from("orgs");
568    for s in segments {
569        let plain = !s.is_empty()
570            && s.bytes()
571                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
572        if !plain {
573            return Err(fail(
574                ACCOUNT_REFUSED,
575                format!("`{s}` isn't a name the account API takes"),
576            ));
577        }
578        path.push('/');
579        path.push_str(s);
580    }
581    Ok(path)
582}
583
584/// `org show`'s text.
585pub(crate) fn render(registry: &str, view: &Value) -> String {
586    let user = |u: &Value| {
587        format!(
588            "{} (#{})",
589            u.get("login").and_then(Value::as_str).unwrap_or("?"),
590            u.get("id").and_then(Value::as_i64).unwrap_or_default()
591        )
592    };
593    let members: Vec<&Value> = view
594        .get("members")
595        .and_then(Value::as_array)
596        .map(|m| m.iter().collect())
597        .unwrap_or_default();
598    let login_of = |id: i64| {
599        members
600            .iter()
601            .copied()
602            .chain(view.get("owner"))
603            .find(|m| m.get("id").and_then(Value::as_i64) == Some(id))
604            .and_then(|m| m.get("login")?.as_str())
605            .map_or_else(
606                || {
607                    if id == 0 {
608                        "an operator".to_owned()
609                    } else {
610                        format!("#{id}")
611                    }
612                },
613                str::to_owned,
614            )
615    };
616    let name = view.get("name").and_then(Value::as_str).unwrap_or("?");
617    let blocked = view.get("blocked").and_then(Value::as_bool) == Some(true);
618    let mut out = format!("{name} on `{registry}`\n");
619    out.push_str(&format!(
620        "  owner:   {}\n",
621        view.get("owner").map(user).unwrap_or_default()
622    ));
623    out.push_str(&format!(
624        "  members: {}\n",
625        members
626            .iter()
627            .map(|m| user(m))
628            .collect::<Vec<_>>()
629            .join(", ")
630    ));
631    out.push_str(&format!(
632        "  blocked: {}\n",
633        if blocked { "yes" } else { "no" }
634    ));
635    let tokens: Vec<&Value> = view
636        .get("tokens")
637        .and_then(Value::as_array)
638        .map(|t| t.iter().collect())
639        .unwrap_or_default();
640    if tokens.is_empty() {
641        out.push_str("  tokens:  none\n");
642    } else {
643        out.push_str("  tokens:\n");
644        for t in tokens {
645            let id = t.get("id").and_then(Value::as_str).unwrap_or("?");
646            let can = t
647                .get("can")
648                .and_then(Value::as_array)
649                .map(|c| {
650                    c.iter()
651                        .filter_map(Value::as_str)
652                        .collect::<Vec<_>>()
653                        .join(", ")
654                })
655                .unwrap_or_default();
656            let until = t
657                .get("expiresAt")
658                .and_then(Value::as_i64)
659                .map(date)
660                .unwrap_or_default();
661            let by = login_of(t.get("mintedBy").and_then(Value::as_i64).unwrap_or(-1));
662            let note = t.get("note").and_then(Value::as_str).unwrap_or_default();
663            out.push_str(&format!(
664                "    {}  {can}  until {until}  minted by {by}",
665                short_id(id)
666            ));
667            if !note.is_empty() {
668                out.push_str(&format!("  {note:?}"));
669            }
670            out.push('\n');
671        }
672    }
673    out
674}
675
676/// The first 12 characters of a token id: enough for `token revoke`.
677fn short_id(id: &str) -> &str {
678    id.get(..12).unwrap_or(id)
679}
680
681// ---------------------------------------------------------------------------
682// Answers
683// ---------------------------------------------------------------------------
684
685/// Server text as the client prints it: control characters removed, so a
686/// hostile registry can't move the cursor or forge another diagnostic.
687fn clean(text: &str) -> String {
688    text.chars().filter(|c| !c.is_control()).collect()
689}
690
691/// A refusal's JSON `error` code.
692fn error_code(body: &[u8]) -> Option<String> {
693    serde_json::from_slice::<Value>(body)
694        .ok()?
695        .get("error")?
696        .as_str()
697        .map(str::to_owned)
698}
699
700fn parse(registry: &str, body: &[u8]) -> Result<Value, FetchError> {
701    serde_json::from_slice(body).map_err(|_| malformed(registry))
702}
703
704fn malformed(registry: &str) -> FetchError {
705    fail(
706        ACCOUNT_REFUSED,
707        format!("registry `{registry}` answered the account API with something unexpected"),
708    )
709}
710
711/// A refusal from the account API, with its JSON `error` and `message`.
712pub(crate) fn refused(registry: &str, status: u16, body: &[u8]) -> FetchError {
713    let v = serde_json::from_slice::<Value>(body).ok();
714    let field = |k: &str| v.as_ref()?.get(k)?.as_str().map(str::to_owned);
715    let mut message = match (field("error"), field("message")) {
716        (Some(code), Some(why)) => {
717            format!(
718                "registry `{registry}` refused it (HTTP {status}, `{}`): {}",
719                clean(&code),
720                clean(&why)
721            )
722        }
723        // `bynk registry serve` answers paths outside its `api` with no body.
724        _ if status == 404 || status == 405 => format!(
725            "registry `{registry}` has no account API (HTTP {status}) — only the first-party registry service has accounts"
726        ),
727        _ => format!("registry `{registry}` refused it (HTTP {status})"),
728    };
729    if status == 401 {
730        message.push_str(&format!(
731            " — sign in again with `bynk login --registry {registry}`"
732        ));
733    }
734    fail(ACCOUNT_REFUSED, message)
735}
736
737// ---------------------------------------------------------------------------
738// Credentials (decisions D and E)
739// ---------------------------------------------------------------------------
740
741/// `BYNK_ACCOUNT_<NAME>_TOKEN`. Not `BYNK_REGISTRY_<NAME>_ACCOUNT_TOKEN`,
742/// which is registry `<name>-account`'s publish token.
743pub fn override_variable(name: &str) -> String {
744    format!(
745        "BYNK_ACCOUNT_{}_TOKEN",
746        name.to_ascii_uppercase().replace('-', "_")
747    )
748}
749
750/// A saved (or exported) account session.
751#[derive(Debug, Clone, PartialEq, Eq)]
752pub(crate) struct Session {
753    pub token: String,
754    pub expires_at: Option<i64>,
755    /// It came from `BYNK_ACCOUNT_<NAME>_TOKEN`.
756    pub from_env: bool,
757}
758
759/// The account token for `registry`: `BYNK_ACCOUNT_<NAME>_TOKEN`, else
760/// `[accounts.<name>]` in the credentials file.
761fn session(registry: &str) -> Option<Session> {
762    if let Some(t) = std::env::var(override_variable(registry))
763        .ok()
764        .filter(|t| !t.trim().is_empty())
765    {
766        return Some(Session {
767            token: t,
768            expires_at: None,
769            from_env: true,
770        });
771    }
772    let doc = read_doc(&super::credentials_path()?).ok()?;
773    saved_session(&doc, registry)
774}
775
776/// `[accounts.<name>]` in `doc`.
777pub(crate) fn saved_session(doc: &toml_edit::DocumentMut, registry: &str) -> Option<Session> {
778    let entry = doc.get("accounts")?.get(registry)?;
779    Some(Session {
780        token: entry.get("token")?.as_str()?.to_owned(),
781        expires_at: entry.get("expires-at").and_then(|v| v.as_integer()),
782        from_env: false,
783    })
784}
785
786fn credentials_file() -> Result<PathBuf, FetchError> {
787    super::credentials_path().ok_or_else(|| {
788        fail(
789            ACCOUNT_REFUSED,
790            "there is no home directory to keep the credentials file in (set `XDG_CONFIG_HOME`)",
791        )
792    })
793}
794
795/// The credentials file, or an empty one when there is none yet.
796pub(crate) fn read_doc(path: &Path) -> Result<toml_edit::DocumentMut, String> {
797    match std::fs::read_to_string(path) {
798        Ok(text) => text
799            .parse()
800            .map_err(|e| format!("`{}` isn't valid TOML: {e}", path.display())),
801        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(toml_edit::DocumentMut::new()),
802        Err(e) => Err(format!("could not read `{}`: {e}", path.display())),
803    }
804}
805
806/// Write the credentials file: private from its first byte (a temporary file
807/// created `0600`, then renamed into place), keeping the rest of it as the
808/// person wrote it.
809pub(crate) fn write_doc(path: &Path, doc: &toml_edit::DocumentMut) -> Result<(), String> {
810    let dir = path
811        .parent()
812        .ok_or("the credentials file has no directory")?;
813    std::fs::create_dir_all(dir)
814        .map_err(|e| format!("could not create `{}`: {e}", dir.display()))?;
815    let tmp = dir.join(format!(".credentials.toml.{}", std::process::id()));
816    let mut options = std::fs::OpenOptions::new();
817    // A fresh file every time: a leftover (or planted) one would keep its
818    // own mode or point elsewhere.
819    let _ = std::fs::remove_file(&tmp);
820    options.write(true).create_new(true);
821    #[cfg(unix)]
822    {
823        use std::os::unix::fs::OpenOptionsExt;
824        options.mode(0o600);
825    }
826    let written = (|| {
827        use std::io::Write;
828        let mut f = options.open(&tmp)?;
829        f.write_all(doc.to_string().as_bytes())?;
830        f.sync_all()?;
831        std::fs::rename(&tmp, path)
832    })();
833    written.map_err(|e| {
834        let _ = std::fs::remove_file(&tmp);
835        format!("could not write `{}`: {e}", path.display())
836    })
837}
838
839/// `[<table>.<name>]`, created (with `<table>` implicit) when missing.
840fn entry<'d>(
841    doc: &'d mut toml_edit::DocumentMut,
842    table: &str,
843    name: &str,
844) -> &'d mut toml_edit::Table {
845    let outer = doc
846        .entry(table)
847        .or_insert_with(|| {
848            let mut t = toml_edit::Table::new();
849            t.set_implicit(true);
850            toml_edit::Item::Table(t)
851        })
852        .as_table_mut()
853        .expect("a table");
854    outer
855        .entry(name)
856        .or_insert_with(toml_edit::table)
857        .as_table_mut()
858        .expect("a table")
859}
860
861/// Keep `signed` as `[accounts.<name>]`.
862pub(crate) fn set_session(doc: &mut toml_edit::DocumentMut, registry: &str, signed: &SignedIn) {
863    let t = entry(doc, "accounts", registry);
864    t["token"] = toml_edit::value(signed.token.as_str());
865    t["login"] = toml_edit::value(signed.login.as_str());
866    t["expires-at"] = toml_edit::value(signed.expires_at);
867}
868
869/// Forget `[accounts.<name>]`; whether there was one.
870pub(crate) fn remove_session(doc: &mut toml_edit::DocumentMut, registry: &str) -> bool {
871    let Some(accounts) = doc.get_mut("accounts").and_then(|a| a.as_table_mut()) else {
872        return false;
873    };
874    let removed = accounts.remove(registry).is_some();
875    if accounts.is_empty() {
876        doc.remove("accounts");
877    }
878    removed
879}
880
881/// R3.2 (#2383, D): whether `token` (or, before minting, any token) may go
882/// in `org`'s own slot, `[registries.<name>.organisations.<org>] token`:
883/// refused when it holds a different one, unless `force`. The registry-wide
884/// slot and other organisations' slots don't matter.
885pub(crate) fn check_publish_slot(
886    doc: &toml_edit::DocumentMut,
887    registry: &str,
888    org: &str,
889    token: Option<&str>,
890    force: bool,
891) -> Result<(), String> {
892    let held = doc
893        .get("registries")
894        .and_then(|r| r.get(registry))
895        .and_then(|r| r.get("organisations"))
896        .and_then(|o| o.get(org))
897        .and_then(|o| o.get("token"))
898        .and_then(|t| t.as_str());
899    match held {
900        Some(h) if !force && token != Some(h) => Err(format!(
901            "`[registries.{registry}.organisations.{org}] token` holds a token already: `--force` replaces it"
902        )),
903        // The slot must be writable too, so a token is never minted and lost.
904        _ => set_publish_token(&mut doc.clone(), registry, org, "-"),
905    }
906}
907
908/// `[registries.<name>.organisations.<org>] token = token`, with the tables
909/// above it implicit when they are new. An inline table on the way is written
910/// into; a key on the way that isn't a table is refused.
911pub(crate) fn set_publish_token(
912    doc: &mut toml_edit::DocumentMut,
913    registry: &str,
914    org: &str,
915    token: &str,
916) -> Result<(), String> {
917    let path = ["registries", registry, "organisations", org];
918    let mut t: &mut dyn toml_edit::TableLike = doc.as_table_mut();
919    for (i, key) in path.into_iter().enumerate() {
920        let new = !t.contains_key(key);
921        let item = t.entry(key).or_insert(toml_edit::table());
922        if new
923            && i + 1 < path.len()
924            && let Some(table) = item.as_table_mut()
925        {
926            table.set_implicit(true);
927        }
928        t = item.as_table_like_mut().ok_or_else(|| {
929            format!(
930                "`{}` in the credentials file isn't a table",
931                path[..=i].join(".")
932            )
933        })?;
934    }
935    t.insert("token", toml_edit::value(token));
936    Ok(())
937}
938
939/// Whether `[registries.<name>] token` holds a token.
940fn registry_wide_token(doc: &toml_edit::DocumentMut, registry: &str) -> bool {
941    doc.get("registries")
942        .and_then(|r| r.get(registry))
943        .and_then(|r| r.get("token"))
944        .is_some()
945}
946
947fn now_millis() -> i64 {
948    std::time::SystemTime::now()
949        .duration_since(std::time::UNIX_EPOCH)
950        .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
951}
952
953/// Epoch milliseconds as a UTC date, `2026-11-22`.
954fn date(millis: i64) -> String {
955    time::OffsetDateTime::from_unix_timestamp(millis.div_euclid(1000))
956        .map_or_else(|_| millis.to_string(), |t| t.date().to_string())
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use std::collections::VecDeque;
963
964    /// Canned answers, and the requests that asked for them.
965    struct Stub {
966        answers: VecDeque<(u16, Value)>,
967        asked: Vec<(String, String, Option<Value>)>,
968    }
969
970    impl Stub {
971        fn new(answers: &[(u16, Value)]) -> Stub {
972            Stub {
973                answers: answers.iter().cloned().collect(),
974                asked: Vec::new(),
975            }
976        }
977    }
978
979    impl Api for Stub {
980        fn call(
981            &mut self,
982            method: &str,
983            path: &str,
984            body: Option<&Value>,
985        ) -> Result<(u16, Vec<u8>), FetchError> {
986            self.asked
987                .push((method.to_owned(), path.to_owned(), body.cloned()));
988            let (status, v) = self.answers.pop_front().expect("an answer left");
989            Ok((status, v.to_string().into_bytes()))
990        }
991    }
992
993    fn problem(code: &str) -> Value {
994        json!({ "error": code, "message": "…" })
995    }
996
997    fn device(interval: u64, expires_in: u64) -> DeviceStart {
998        DeviceStart {
999            poll: "p1".into(),
1000            user_code: "WDJB-MJHT".into(),
1001            verification_uri: "https://github.com/login/device".into(),
1002            interval,
1003            expires_in,
1004        }
1005    }
1006
1007    /// Runs `poll`, recording each wait, with time passing only by waits.
1008    fn run(stub: &mut Stub, start: &DeviceStart) -> (Result<SignedIn, FetchError>, Vec<u64>) {
1009        let mut waits = Vec::new();
1010        let mut elapsed = Duration::ZERO;
1011        let got = poll(stub, "bynk", start, &mut |d| {
1012            waits.push(d.as_secs());
1013            elapsed += d;
1014            elapsed
1015        });
1016        (got, waits)
1017    }
1018
1019    #[test]
1020    fn the_start_is_read_from_its_json() {
1021        let mut stub = Stub::new(&[(
1022            200,
1023            json!({ "poll": "p1", "userCode": "WDJB-MJHT", "verificationUri": "https://github.com/login/device", "interval": 5, "expiresIn": 900 }),
1024        )]);
1025        assert_eq!(start(&mut stub, "bynk").unwrap(), device(5, 900));
1026        assert_eq!(stub.asked[0].0, "POST");
1027        assert_eq!(stub.asked[0].1, "login");
1028    }
1029
1030    #[test]
1031    fn polling_waits_the_interval_and_slows_down_by_five_seconds() {
1032        let mut stub = Stub::new(&[
1033            (400, problem("authorization_pending")),
1034            (429, problem("slow_down")),
1035            (400, problem("authorization_pending")),
1036            (
1037                200,
1038                json!({ "token": "bynka_x", "login": "ann", "expiresAt": 1_795_392_000_000_i64 }),
1039            ),
1040        ]);
1041        let (got, waits) = run(&mut stub, &device(2, 900));
1042        assert_eq!(
1043            got.unwrap(),
1044            SignedIn {
1045                token: "bynka_x".into(),
1046                login: "ann".into(),
1047                expires_at: 1_795_392_000_000,
1048            }
1049        );
1050        assert_eq!(waits, [2, 2, 7, 7]);
1051        assert!(stub.asked.iter().all(|(m, p, b)| m == "POST"
1052            && p == "login/poll"
1053            && b == &Some(json!({ "poll": "p1" }))));
1054    }
1055
1056    #[test]
1057    fn polling_stops_on_expiry_denial_and_the_local_deadline() {
1058        let (got, _) = run(
1059            &mut Stub::new(&[(410, problem("expired_token"))]),
1060            &device(1, 900),
1061        );
1062        assert!(got.unwrap_err().message.contains("expired"));
1063        let (got, _) = run(
1064            &mut Stub::new(&[(403, problem("access_denied"))]),
1065            &device(1, 900),
1066        );
1067        assert!(got.unwrap_err().message.contains("declined"));
1068        // A server that never says `expired_token` can't keep the client
1069        // waiting past `expiresIn`.
1070        let pending = vec![(400, problem("authorization_pending")); 3];
1071        let mut stub = Stub::new(&pending);
1072        let (got, waits) = run(&mut stub, &device(5, 12));
1073        assert!(got.unwrap_err().message.contains("expired"));
1074        assert_eq!(waits, [5, 5, 5]);
1075        assert_eq!(stub.asked.len(), 2);
1076    }
1077
1078    #[test]
1079    fn a_zero_interval_still_waits() {
1080        let mut stub = Stub::new(&[
1081            (400, problem("authorization_pending")),
1082            (
1083                200,
1084                json!({ "token": "bynka_x", "login": "ann", "expiresAt": 0 }),
1085            ),
1086        ]);
1087        let (got, waits) = run(&mut stub, &device(0, 900));
1088        assert!(got.is_ok());
1089        assert_eq!(waits, [1, 1]);
1090    }
1091
1092    #[test]
1093    fn server_text_is_printed_without_control_characters() {
1094        let body = json!({ "error": "x\u{1b}[2K", "message": "ok\rerror[bynk.fake]: no" });
1095        let e = refused("bynk", 403, body.to_string().as_bytes());
1096        assert!(!e.message.chars().any(char::is_control), "{:?}", e.message);
1097    }
1098
1099    #[test]
1100    fn polling_branches_on_the_error_code_not_the_status() {
1101        // A `400` that isn't `authorization_pending` is a refusal, not a wait.
1102        let (got, _) = run(
1103            &mut Stub::new(&[(400, problem("bad_request"))]),
1104            &device(0, 900),
1105        );
1106        let e = got.unwrap_err();
1107        assert_eq!(e.code, ACCOUNT_REFUSED);
1108        assert!(e.message.contains("`bad_request`"), "{}", e.message);
1109    }
1110
1111    #[test]
1112    fn a_401_says_to_sign_in_again() {
1113        let e = refused("bynk", 401, problem("unauthorised").to_string().as_bytes());
1114        assert!(
1115            e.message.contains("bynk login --registry bynk"),
1116            "{}",
1117            e.message
1118        );
1119        let e = refused("acme", 404, b"not json");
1120        assert!(e.message.contains("no account API"), "{}", e.message);
1121        let e = refused("acme", 405, b"");
1122        assert!(
1123            e.message.contains("no account API (HTTP 405)"),
1124            "{}",
1125            e.message
1126        );
1127    }
1128
1129    #[test]
1130    fn sessions_are_kept_apart_from_publish_tokens() {
1131        let mut doc: toml_edit::DocumentMut =
1132            "# mine\n[registries.bynk]\ntoken = \"bynk_old\" # laptop\n"
1133                .parse()
1134                .unwrap();
1135        set_session(
1136            &mut doc,
1137            "bynk",
1138            &SignedIn {
1139                token: "bynka_new".into(),
1140                login: "ann".into(),
1141                expires_at: 7,
1142            },
1143        );
1144        let text = doc.to_string();
1145        assert!(
1146            text.starts_with("# mine\n[registries.bynk]\ntoken = \"bynk_old\" # laptop\n"),
1147            "{text}"
1148        );
1149        assert!(
1150            text.contains(
1151                "[accounts.bynk]\ntoken = \"bynka_new\"\nlogin = \"ann\"\nexpires-at = 7\n"
1152            ),
1153            "{text}"
1154        );
1155        assert_eq!(
1156            saved_session(&doc, "bynk"),
1157            Some(Session {
1158                token: "bynka_new".into(),
1159                expires_at: Some(7),
1160                from_env: false,
1161            })
1162        );
1163        assert!(remove_session(&mut doc, "bynk"));
1164        assert!(!remove_session(&mut doc, "bynk"));
1165        assert_eq!(
1166            doc.to_string(),
1167            "# mine\n[registries.bynk]\ntoken = \"bynk_old\" # laptop\n"
1168        );
1169    }
1170
1171    #[test]
1172    fn saving_a_publish_token_never_replaces_another_without_force() {
1173        let mut doc = toml_edit::DocumentMut::new();
1174        assert!(check_publish_slot(&doc, "bynk", "acme", None, false).is_ok());
1175        set_publish_token(&mut doc, "bynk", "acme", "bynk_a").unwrap();
1176        assert_eq!(
1177            doc.to_string(),
1178            "[registries.bynk.organisations.acme]\ntoken = \"bynk_a\"\n"
1179        );
1180        assert!(check_publish_slot(&doc, "bynk", "acme", None, false).is_err());
1181        assert!(check_publish_slot(&doc, "bynk", "acme", Some("bynk_b"), false).is_err());
1182        assert!(check_publish_slot(&doc, "bynk", "acme", Some("bynk_a"), false).is_ok());
1183        assert!(check_publish_slot(&doc, "bynk", "acme", Some("bynk_b"), true).is_ok());
1184        // R3.2 (#2383, D): the guard is per organisation.
1185        assert!(check_publish_slot(&doc, "bynk", "acme-labs", None, false).is_ok());
1186        assert!(check_publish_slot(&doc, "other", "acme", None, false).is_ok());
1187    }
1188
1189    #[test]
1190    fn an_organisation_s_slot_sits_beside_the_registry_wide_one() {
1191        let mut doc: toml_edit::DocumentMut =
1192            "# mine\n[registries.bynk]\ntoken = \"bynk_wide\" # laptop\n"
1193                .parse()
1194                .unwrap();
1195        // The registry-wide slot doesn't block an organisation's own.
1196        assert!(check_publish_slot(&doc, "bynk", "acme", None, false).is_ok());
1197        assert!(registry_wide_token(&doc, "bynk"));
1198        set_publish_token(&mut doc, "bynk", "acme", "bynk_acme").unwrap();
1199        set_publish_token(&mut doc, "bynk", "acme-labs", "bynk_labs").unwrap();
1200        assert_eq!(
1201            doc.to_string(),
1202            "# mine\n[registries.bynk]\ntoken = \"bynk_wide\" # laptop\n\n\
1203             [registries.bynk.organisations.acme]\ntoken = \"bynk_acme\"\n\n\
1204             [registries.bynk.organisations.acme-labs]\ntoken = \"bynk_labs\"\n"
1205        );
1206        assert!(!registry_wide_token(&doc, "other"));
1207    }
1208
1209    #[test]
1210    fn an_inline_organisations_table_is_written_into_and_a_non_table_refused() {
1211        let mut doc: toml_edit::DocumentMut =
1212            "[registries.bynk]\norganisations = { globex = { token = \"bynk_g\" } }\n"
1213                .parse()
1214                .unwrap();
1215        assert!(check_publish_slot(&doc, "bynk", "acme", None, false).is_ok());
1216        set_publish_token(&mut doc, "bynk", "acme", "bynk_a").unwrap();
1217        let text = doc.to_string();
1218        let parsed: toml::Table = text.parse().unwrap();
1219        let orgs = &parsed["registries"]["bynk"]["organisations"];
1220        assert_eq!(orgs["globex"]["token"].as_str(), Some("bynk_g"), "{text}");
1221        assert_eq!(orgs["acme"]["token"].as_str(), Some("bynk_a"), "{text}");
1222        // Refused before minting, so no live token is ever lost.
1223        let doc: toml_edit::DocumentMut = "[registries.bynk]\norganisations = \"x\"\n"
1224            .parse()
1225            .unwrap();
1226        let err = check_publish_slot(&doc, "bynk", "acme", None, false).unwrap_err();
1227        assert_eq!(
1228            err,
1229            "`registries.bynk.organisations` in the credentials file isn't a table"
1230        );
1231    }
1232
1233    #[test]
1234    fn an_organisation_named_like_its_registry_writes_no_empty_header() {
1235        let mut doc = toml_edit::DocumentMut::new();
1236        set_publish_token(&mut doc, "bynk", "bynk", "bynk_a").unwrap();
1237        assert_eq!(
1238            doc.to_string(),
1239            "[registries.bynk.organisations.bynk]\ntoken = \"bynk_a\"\n"
1240        );
1241    }
1242
1243    #[cfg(unix)]
1244    #[test]
1245    fn the_credentials_file_is_written_private() {
1246        use std::os::unix::fs::PermissionsExt;
1247        let dir = std::env::temp_dir().join(format!("bynk-account-{}", std::process::id()));
1248        let path = dir.join("bynk").join("credentials.toml");
1249        let mut doc = read_doc(&path).unwrap();
1250        set_publish_token(&mut doc, "bynk", "acme", "bynk_a").unwrap();
1251        write_doc(&path, &doc).unwrap();
1252        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1253        assert_eq!(mode & 0o777, 0o600);
1254        assert_eq!(read_doc(&path).unwrap().to_string(), doc.to_string());
1255        std::fs::remove_dir_all(&dir).unwrap();
1256    }
1257
1258    #[test]
1259    fn members_and_tokens_are_found_by_login_and_prefix() {
1260        let view = json!({
1261            "name": "acme",
1262            "owner": { "id": 1, "login": "ann" },
1263            "members": [{ "id": 1, "login": "ann" }, { "id": 2, "login": "Bob" }],
1264            "blocked": false,
1265            "tokens": [
1266                { "id": "7f3a00", "can": ["publish"], "expiresAt": 0, "note": "", "mintedBy": 2 },
1267                { "id": "7f3b00", "can": ["publish", "yank"], "expiresAt": 0, "note": "ci", "mintedBy": 0 },
1268            ],
1269        });
1270        assert_eq!(find_member(&view, "bob").as_deref(), Some("2"));
1271        assert_eq!(find_member(&view, "cy"), None);
1272        assert_eq!(find_token(&view, "7f3a").unwrap(), "7f3a00");
1273        assert!(find_token(&view, "7f3").unwrap_err().contains("2 tokens"));
1274        assert!(find_token(&view, "00").is_err());
1275        assert!(find_token(&view, "").is_err());
1276        assert_eq!(
1277            render("bynk", &view),
1278            "acme on `bynk`\n  owner:   ann (#1)\n  members: ann (#1), Bob (#2)\n  blocked: no\n  tokens:\n    7f3a00  publish  until 1970-01-01  minted by Bob\n    7f3b00  publish, yank  until 1970-01-01  minted by an operator  \"ci\"\n"
1279        );
1280    }
1281
1282    #[test]
1283    fn a_path_segment_must_be_a_plain_name() {
1284        assert_eq!(
1285            orgs(&["acme", "members", "bob"]).unwrap(),
1286            "orgs/acme/members/bob"
1287        );
1288        assert!(orgs(&["..", "x"]).is_err());
1289        assert!(orgs(&["acme/x"]).is_err());
1290        assert!(orgs(&[""]).is_err());
1291    }
1292
1293    #[test]
1294    fn the_override_variable_cannot_collide_with_a_publish_variable() {
1295        assert_eq!(
1296            override_variable("acme-internal"),
1297            "BYNK_ACCOUNT_ACME_INTERNAL_TOKEN"
1298        );
1299        assert_ne!(
1300            override_variable("foo"),
1301            bynk_project::manifest::registry_token_var("foo-account")
1302        );
1303        assert_eq!(date(1_795_392_000_000), "2026-11-23");
1304    }
1305}