Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/locallibrary/changes/account-ensure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
### Added

- **Scopes asked for when a plugin needs them.** A plugin asks for the scopes it can do without
as it needs them (`accounts/ensure`, `Host::ensure_account` in the SDK), and learns which it
may use and which you declined, never a token. The terminal and the player ask you first
(signing in again when the account wasn't granted them), and a no is remembered until `plugin
access --give` gives the scope; with `--json`, in `watch` and in the sidecar it fails saying how
to give it.
- **When an account signs itself out.** `account` says when each account signs itself out (its
token expiring unrefreshed, or its refresh token running out when the service says), and in its
last two days it's a warning there, before a destination uses it (offering to sign in again) and
as the player starts; the Atmosphere account's "sign in again by" turns into a warning then too,
and publishing offers to sign in again first.
12 changes: 11 additions & 1 deletion apps/locallibrary/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,18 @@ In the player's palette, "sign in to a TYPE account…" signs in to one, "sign o
out of one, and "access for PLUGIN…" opens a form choosing which account of each type the plugin
uses, saying what for, or signing in to another.

A plugin asks for the scopes it can do without when it needs them. In a terminal, and in the
player, you're asked whether it may, and why it wants them; when the account wasn't granted them,
allowing it signs in again in the browser. A no is remembered: it isn't asked again until
`locallibrary plugin access PLUGIN --give ACCOUNT --scope SCOPE` gives the scope. With `--json`,
`--yes` or no terminal, in `watch` and in the sidecar, nothing's asked: the plugin fails, saying
how to give it the scope.

The tokens are kept in the system's keychain, as secret settings are (see [Settings](#settings)),
and refreshed a minute before they expire. A plugin never sees them: a request it makes names the
and refreshed a minute before they expire. An account that isn't refreshed signs itself out as its
token expires, and one whose service says how long its refresh token lasts signs itself out then:
`locallibrary account` says when (`signs_out` with `--json`), and in its last two days warns, as a
destination using it does (offering to sign in again in a terminal) and the player as it starts. A plugin never sees them: a request it makes names the
account by its type (`{account:TYPE}`) where the type says its token goes, the `Authorization`
header unless the service only takes it in the address (Mixcloud), and locallibrary fills in the
token once it has checked the request against the scopes of the plugin's access, never sending it
Expand Down
25 changes: 22 additions & 3 deletions apps/locallibrary/docs/rpc/locallibrary.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8149,6 +8149,12 @@ export type StoreAccount = {
expires?: string | null;
/** It's refreshed without signing in again. */
refreshes: boolean;
/**
* When it signs itself out, when it does: as its access token expires when it isn't
* refreshed, else as its refresh token runs out, when the service said. Front ends warn
* within [`crate::plugin::WARN_DAYS`] of it.
*/
signs_out?: string | null;
/** Its tokens are kept (the keychain may have lost them). */
signed_in: boolean;
};
Expand All @@ -8168,9 +8174,10 @@ export type Access = {
/** It's the plugin's default for the type: `{account:<type>}` names it. */
default: boolean;
/**
* The scopes (by name) a sign-in for it asked the service for, which didn't grant all they
* ask, until it's given them again. Those it needs are in `scopes` too, and still can't be
* used.
* The scopes (by name) the user declined when the plugin asked for them, or a sign-in for it
* asked the service for and the service didn't grant all they ask, until it's given them
* again: the plugin isn't asked about them till then. Those it needs are in `scopes` too, and
* still can't be used.
*/
declined?: string[];
};
Expand Down Expand Up @@ -8209,6 +8216,12 @@ export type AccountSignedIn = {
expires?: string | null;
/** It's refreshed without signing in again. */
refreshes: boolean;
/**
* When it signs itself out, when it does: as its access token expires when it isn't
* refreshed, else as its refresh token runs out, when the service said. Front ends warn
* within [`crate::plugin::WARN_DAYS`] of it.
*/
signs_out?: string | null;
/** Its tokens are kept (the keychain may have lost them). */
signed_in: boolean;
/**
Expand Down Expand Up @@ -8243,6 +8256,12 @@ export type AccountSignedOut = {
expires?: string | null;
/** It's refreshed without signing in again. */
refreshes: boolean;
/**
* When it signs itself out, when it does: as its access token expires when it isn't
* refreshed, else as its refresh token runs out, when the service said. Front ends warn
* within [`crate::plugin::WARN_DAYS`] of it.
*/
signs_out?: string | null;
/** Its tokens are kept (the keychain may have lost them). */
signed_in: boolean;
/** The service was told to forget its tokens (it has a revocation endpoint, which answered). */
Expand Down
26 changes: 25 additions & 1 deletion apps/locallibrary/docs/rpc/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -31210,6 +31210,14 @@
"type": "boolean",
"description": "It's refreshed without signing in again."
},
"signs_out": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "When it signs itself out, when it does: as its access token expires when it isn't\nrefreshed, else as its refresh token runs out, when the service said. Front ends warn\nwithin [`crate::plugin::WARN_DAYS`] of it."
},
"signed_in": {
"type": "boolean",
"description": "Its tokens are kept (the keychain may have lost them)."
Expand Down Expand Up @@ -31264,7 +31272,7 @@
"items": {
"type": "string"
},
"description": "The scopes (by name) a sign-in for it asked the service for, which didn't grant all they\nask, until it's given them again. Those it needs are in `scopes` too, and still can't be\nused."
"description": "The scopes (by name) the user declined when the plugin asked for them, or a sign-in for it\nasked the service for and the service didn't grant all they ask, until it's given them\nagain: the plugin isn't asked about them till then. Those it needs are in `scopes` too, and\nstill can't be used."
}
},
"required": [
Expand Down Expand Up @@ -31347,6 +31355,14 @@
"type": "boolean",
"description": "It's refreshed without signing in again."
},
"signs_out": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "When it signs itself out, when it does: as its access token expires when it isn't\nrefreshed, else as its refresh token runs out, when the service said. Front ends warn\nwithin [`crate::plugin::WARN_DAYS`] of it."
},
"signed_in": {
"type": "boolean",
"description": "Its tokens are kept (the keychain may have lost them)."
Expand Down Expand Up @@ -31425,6 +31441,14 @@
"type": "boolean",
"description": "It's refreshed without signing in again."
},
"signs_out": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "When it signs itself out, when it does: as its access token expires when it isn't\nrefreshed, else as its refresh token runs out, when the service said. Front ends warn\nwithin [`crate::plugin::WARN_DAYS`] of it."
},
"signed_in": {
"type": "boolean",
"description": "Its tokens are kept (the keychain may have lost them)."
Expand Down
10 changes: 6 additions & 4 deletions apps/locallibrary/docs/sidecar.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,12 @@ Native bindings (napi-rs) can wrap the same `api` functions later without changi
The accounts a store keeps are signed in to through account plugins, and plugins use them through
the access they're given (see [plugins.md](plugins.md#accounts)). No answer holds a token.
`accounts.list` answers `{accounts, accesses}`: each account kept (`{type, plugin, subject, name,
scopes, expires?, refreshes, signed_in}`, `plugin` the account plugin it was signed in through),
then each one signed out that a plugin still has access to, `signed_in: false`; and each access
(`{id, plugin, type, account: {plugin, subject}, name, scopes, default, declined?}`, `declined`
the scopes the service didn't grant at the last sign-in for that plugin).
scopes, expires?, refreshes, signs_out?, signed_in}`, `plugin` the account plugin it was signed in
through, `signs_out` when it signs itself out: as `expires` when it isn't refreshed, else as its
refresh token runs out when the service said), then each one signed out that a plugin still has
access to, `signed_in: false`; and each access (`{id, plugin, type, account: {plugin, subject},
name, scopes, default, declined?}`, `declined` the scopes the user declined when the plugin asked
for them, or the service didn't grant at a sign-in for that plugin, until they're given again).

`accounts.sign_in.begin {type, with?, for?, account?, scopes?}` starts a sign-in through the
account plugin `with` (the only one providing the type when it's left out), for the plugin `for`
Expand Down
8 changes: 6 additions & 2 deletions apps/locallibrary/src/account_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
//! `locallibrary account`: the accounts the store keeps. With no arguments, the DJ account (an
//! Atmosphere account, see [`atmosphere`]) then every other; `account TYPE sign-in` signs in to
//! one through an account plugin, and `account TYPE:NAME status|sign-out` shows one or signs out
//! of it (see [`accounts`]). [`access`] gives plugins access to them.
//! of it (see [`accounts`]). [`access`] gives plugins access to them, and [`asking`] asks in the
//! terminal when a plugin wants more of one.

pub mod access;
mod accounts;
pub mod asking;
#[cfg(feature = "account")]
mod atmosphere;

Expand All @@ -19,8 +21,10 @@ use locallibrary::plugin::Accounts;
use locallibrary::progress::Ctx;
use serde::Serialize;

#[cfg(feature = "tui")]
pub use accounts::signing_out;
#[cfg(feature = "account")]
pub use atmosphere::sign_in_again;
pub use atmosphere::{again, sign_in_again};

use crate::output::Output;
use crate::plugins;
Expand Down
47 changes: 38 additions & 9 deletions apps/locallibrary/src/account_cmd/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ use locallibrary::progress::Ctx;
use loclib_plugin::{AccountNeed, Choice, ChoiceItem};
use serde_json::json;

use super::accounts::{account_type, called, come_back, declined, find, named, titles};
use super::accounts::{account_type, called, come_back, declined, find, named, signing_out, titles};
use crate::browser::BrowserArgs;
use crate::jobs_cmd::usage;
use crate::output::{Output, dim, heading, ok};
use crate::output::{Output, dim, heading, ok, warn};
use crate::plugin_cmd::choose::{answer, interactive};
use crate::plugins;

Expand Down Expand Up @@ -110,21 +110,50 @@ fn list(plugins: &Plugins, plugin: &str, needs: &[AccountNeed], accesses: &[Acce
/// asked about once: which account it may use when it has no access (made its default, signing in
/// to another when that's chosen), signing in again when the account is signed out, won't stay
/// signed in or wasn't granted what it needs, and giving it the scopes its access lacks. Nothing's
/// asked elsewhere: the destination is refused, saying how to resolve it.
/// asked elsewhere: the destination is refused, saying how to resolve it. Then each account it
/// uses that signs itself out within `WARN_DAYS` is warned about, and signing in again offered
/// in a terminal (see [`signing_out_soon`]).
pub fn ask(spec: &str, store: &Store, out: Output, ctx: &Ctx<'_>) -> Result<()> {
if !interactive(out) || out.yes {
return Ok(());
}
let (plugin, _) = destination_spec(spec)?;
// A spec that names no destination is refused as it's used.
let Ok((plugin, _)) = destination_spec(spec) else { return Ok(()) };
let plugins = plugins::host()?;
let asking = interactive(out) && !out.yes;
// One turned down isn't asked again: the destination refuses it.
let mut asked = Vec::new();
while let Some(u) =
plugins.unready(plugin, store)?.into_iter().find(|u| !asked.contains(&(u.kind().to_string(), discriminant(u))))
while let Some(u) = plugins
.unready(plugin, store)?
.into_iter()
.find(|u| asking && !asked.contains(&(u.kind().to_string(), discriminant(u))))
{
asked.push((u.kind().to_string(), discriminant(&u)));
resolve(plugins, plugin, u, store, out, ctx)?;
}
signing_out_soon(plugins, plugin, store, asking, out, ctx)
}

/// Warns of each account `plugin`'s default accesses use that signs itself out within `WARN_DAYS`,
/// and when `asking`, offers to sign in again as it.
fn signing_out_soon(
plugins: &Plugins,
plugin: &str,
store: &Store,
asking: bool,
out: Output,
ctx: &Ctx<'_>,
) -> Result<()> {
// Without asking the keychain: one that lost its tokens is signed out, which `ask` resolves.
let accounts = plugins.kept_accounts(store)?;
for x in plugins.accesses(store, Some(plugin), None)?.into_iter().filter(|x| x.default) {
let used = accounts.iter().find(|a| a.plugin == x.account.plugin && a.subject == x.account.subject);
let Some((a, at)) = used.filter(|a| a.due_soon()).and_then(|a| Some((a, a.signs_out?))) else { continue };
warn(format!("the {} {}", called(plugins, a), signing_out(at)));
if !asking || !out.confirm("Sign in again now?") {
continue;
}
let start = plugins.begin_account_sign_in(&a.kind, &a.plugin, Some(plugin), Some(&a.subject), &[], store)?;
let done = come_back(plugins, &start, BrowserArgs::default(), out, ctx)?;
ok(format!("Signed in to the {}", called(plugins, &done.account)));
}
Ok(())
}

Expand Down
32 changes: 25 additions & 7 deletions apps/locallibrary/src/account_cmd/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,20 @@ pub(super) fn declined(plugins: &Plugins, d: &AccountSignedIn) {
warn(format!("the service didn't allow {} to {}", access.plugin, titles(t, &d.declined).join(", ")));
}

/// Each of `all`'s accounts: its type, name and account plugin, whether it's signed in and until
/// when, what it was granted, and which plugins have access to it.
/// When an account that signs itself out at `at` does: on its day and in how many days, at its
/// time when that's today, or that it has.
pub fn signing_out(at: chrono::DateTime<chrono::Utc>) -> String {
let left = at - chrono::Utc::now();
match left.num_days() {
_ if left <= chrono::Duration::zero() => format!("signed itself out at {}", local_time(at, "%Y-%m-%d %H:%M")),
0 => format!("signs itself out at {} (today)", local_time(at, "%Y-%m-%d %H:%M")),
days => format!("signs itself out on {} ({days} days)", local_time(at, "%Y-%m-%d")),
}
}

/// Each of `all`'s accounts: its type, name and account plugin, whether it's signed in and when
/// it signs itself out (warning in its last days), what it was granted, and which plugins have
/// access to it.
pub(super) fn list(plugins: &Plugins, all: &Accounts) {
for a in &all.accounts {
let accesses: Vec<&Access> =
Expand All @@ -192,16 +204,22 @@ pub(super) fn list(plugins: &Plugins, all: &Accounts) {

fn show(plugins: &Plugins, a: &Account, accesses: &[&Access]) {
heading(&called(plugins, a));
let state = match (a.signed_in, a.refreshes, a.expires) {
(false, ..) => format!("signed out: `locallibrary account {}:{} sign-in` signs in again", a.kind, a.subject),
(true, true, _) => "signed in, refreshed on its own".into(),
(true, false, Some(at)) => format!("signed in until {}", local_time(at, "%Y-%m-%d %H:%M")),
(true, false, None) => "signed in".into(),
let again = format!("`locallibrary account {}:{} sign-in` signs in again", a.kind, a.subject);
let mut state = match (a.signed_in, a.refreshes) {
(false, _) => format!("signed out: {again}"),
(true, true) => "signed in, refreshed on its own".into(),
(true, false) => "signed in".into(),
};
if let Some(at) = a.signs_out.filter(|_| a.signed_in) {
state = format!("{state} · {}", signing_out(at));
}
match a.scopes.is_empty() {
true => say!(" {}", dim(state)),
false => say!(" {}", dim(format!("{state} · allowed: {}", a.scopes.join(" ")))),
}
if let Some(at) = a.signs_out.filter(|_| a.due_soon()) {
warn(format!("it {}: {again}", signing_out(at)));
}
if !accesses.is_empty() {
let who: Vec<String> = accesses
.iter()
Expand Down
35 changes: 35 additions & 0 deletions apps/locallibrary/src/account_cmd/asking.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//! A plugin wanting more of an account it has access to (`accounts/ensure`), asked about in the
//! terminal a command runs in, with its progress bar out of the way: allowing it, or signing in
//! again in the browser to, when the account wasn't granted what it asks.

use locallibrary::plugin::{AccessQuestion, AskAccess};

use crate::browser;
use crate::output::{Drawing, Output, dim};

/// Asks on stderr, reading the answer from the terminal.
pub struct Terminal {
pub out: Output,
pub drawing: Drawing,
}

/// What `q`'s plugin wants, and why.
pub fn wants(q: &AccessQuestion) -> String {
let a = &q.access;
let (titles, why) = (q.titles.join(", "), q.why.join("; "));
format!("{} wants to {titles} with the {} account {} ({why})", q.plugin, q.title, a.name)
}

impl AskAccess for Terminal {
fn allow(&self, q: &AccessQuestion) -> Option<bool> {
let allow = if q.sign_in { "Sign in again to allow it?" } else { "Allow it?" };
Some(self.drawing.suspend(|| self.out.confirm(&format!("{}. {allow}", wants(q)))))
}

fn sign_in(&self, url: &str) {
self.drawing.suspend(|| {
browser::open(url, true);
eprintln!("{}", dim("Waiting for you to approve (Ctrl-C stops)…"));
});
}
}
21 changes: 15 additions & 6 deletions apps/locallibrary/src/account_cmd/atmosphere.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,17 +277,26 @@ fn render_account(a: &AccountInfo) {
render_details(a);
}

fn render_details(a: &AccountInfo) {
let host = |url: &str| url.split("://").nth(1).unwrap_or(url).trim_end_matches('/').to_string();
say!(" {}", dim(format!("{} · data on {} · signed in at {}", a.did, host(&a.pds), host(&a.auth_server))));
let again = if a.days_left() >= 1 {
/// When `a` is to sign in again: by its day and in how many days, by its time when that's today,
/// or that its session has likely ended.
pub fn again(a: &AccountInfo) -> String {
if a.days_left() >= 1 {
format!("sign in again by {} ({} days)", local_time(a.sign_in_again_by, "%Y-%m-%d"), a.days_left())
} else if !a.expired() {
format!("sign in again by {} (today)", local_time(a.sign_in_again_by, "%Y-%m-%d %H:%M"))
} else {
"the session has likely ended; sign in again".to_string()
};
say!(" signed in {} · {again}", local_time(a.signed_in_at, "%Y-%m-%d"));
}
}

fn render_details(a: &AccountInfo) {
let host = |url: &str| url.split("://").nth(1).unwrap_or(url).trim_end_matches('/').to_string();
say!(" {}", dim(format!("{} · data on {} · signed in at {}", a.did, host(&a.pds), host(&a.auth_server))));
let line = format!("signed in {} · {}", local_time(a.signed_in_at, "%Y-%m-%d"), again(a));
match a.due_soon() {
true => warn(line),
false => say!(" {line}"),
}
let publishes = a.scopes.iter().any(|s| atmosphere::PUBLISH_SCOPES.contains(&s.as_str()));
let reports = a.scopes.iter().any(|s| s == atmosphere::REPORT_SCOPE);
let known = |s: &String| {
Expand Down
Loading
Loading