use mcp_kit::licence::{self, Licence, LicenceError, LicencePolicy};
use std::fmt;
use std::path::PathBuf;
pub const PRO_PUBLIC_KEYS: &[&str] = &["xO9jSvEq5nsVPMk9x62Egr0_n5WPpWCF8yCYmrwzH3Y"];
pub const PRO_URL: &str = "https://sylphxai.github.io/anymd/pro";
pub const TOKEN_ENV: &str = "ANYMD_PRO_TOKEN";
#[allow(dead_code)]
const CHECKOUT_BASE: &str = "https://buy.sylphx.com";
fn policy_with<'a>(keys: &'a [&'a str], checkout_base: Option<&'a str>) -> LicencePolicy<'a> {
LicencePolicy {
product: "anymd",
require_product: false,
accepted_plans: &["pro"],
public_keys: keys,
env_var: TOKEN_ENV,
file_name: "pro-token",
upgrade_url: PRO_URL,
tier: "Pro",
checkout_base,
}
}
fn policy() -> LicencePolicy<'static> {
policy_with(PRO_PUBLIC_KEYS, None)
}
pub type ProLicense = Licence;
pub type LicenseError = LicenceError;
pub fn verify_token(token: &str) -> Result<ProLicense, LicenseError> {
policy().verify(token)
}
pub fn token_path() -> Option<PathBuf> {
policy().token_path()
}
pub fn current_license() -> Option<ProLicense> {
policy().current()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProRequired {
pub feature: String,
}
impl fmt::Display for ProRequired {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} is part of anymd Pro: US$29 once for an offline licence. Buy it with `anymd pro buy`. Learn more: {PRO_URL}",
self.feature
)
}
}
impl std::error::Error for ProRequired {}
pub fn require_pro(feature: &str) -> Result<(), ProRequired> {
require_pro_with(feature, current_license().is_some())
}
pub(crate) fn require_pro_with(feature: &str, active: bool) -> Result<(), ProRequired> {
if active {
Ok(())
} else {
Err(ProRequired {
feature: feature.to_string(),
})
}
}
pub fn required_result(required: &ProRequired) -> rmcp::model::CallToolResult {
rmcp::model::CallToolResult::success(vec![rmcp::model::ContentBlock::text(
required.to_string(),
)])
}
pub fn run(arguments: &[String]) -> i32 {
run_with(&policy(), arguments)
}
fn run_with(policy: &LicencePolicy, arguments: &[String]) -> i32 {
let ok = match arguments.first().map(String::as_str) {
Some("status") => arguments.len() == 1,
Some("activate") => arguments.len() == 2,
Some("buy") => arguments[1..]
.iter()
.all(|a| a == "--no-browser" || a == "--json"),
_ => false,
};
if !ok {
eprintln!(
"usage: anymd pro status | anymd pro activate <token> | anymd pro buy [--no-browser] [--json]"
);
return 2;
}
licence::run_cli(policy, arguments)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
fn verify_token_with(token: &str, keys: &[&str]) -> Result<ProLicense, LicenseError> {
policy_with(keys, None).verify(token)
}
fn key(seed: u8) -> SigningKey {
SigningKey::from_bytes(&[seed; 32])
}
fn public(key: &SigningKey) -> String {
URL_SAFE_NO_PAD.encode(key.verifying_key().to_bytes())
}
fn token(key: &SigningKey, payload: &str) -> String {
let sig = key.sign(payload.as_bytes());
format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(payload),
URL_SAFE_NO_PAD.encode(sig.to_bytes())
)
}
const PRO: &str = r#"{"plan":"pro","email":"a@example.com","issuedAt":1700000000}"#;
#[test]
fn valid_token() {
let k = key(1);
let l = verify_token_with(&token(&k, PRO), &[&public(&k)]).unwrap();
assert_eq!(l.plan, "pro");
assert_eq!(l.email.as_deref(), Some("a@example.com"));
assert_eq!(l.issued_at, 1_700_000_000);
let l = verify_token_with(
&token(&k, r#"{"plan":"pro","issuedAt":5}"#),
&["bogus", &public(&key(9)), &public(&k)],
)
.unwrap();
assert_eq!(l.email, None);
}
#[test]
fn bad_signature() {
let (k, other) = (key(1), key(2));
assert_eq!(
verify_token_with(&token(&other, PRO), &[&public(&k)]),
Err(LicenseError::BadSignature)
);
let t = token(&k, PRO);
let sig = t.split_once('.').unwrap().1;
let forged = format!(
"{}.{sig}",
URL_SAFE_NO_PAD.encode(r#"{"plan":"pro","issuedAt":1}"#)
);
assert_eq!(
verify_token_with(&forged, &[&public(&k)]),
Err(LicenseError::BadSignature)
);
assert_eq!(verify_token(&t), Err(LicenseError::BadSignature));
}
#[test]
fn wrong_plan() {
let k = key(1);
assert_eq!(
verify_token_with(
&token(&k, r#"{"plan":"free","issuedAt":1}"#),
&[&public(&k)]
),
Err(LicenseError::WrongPlan)
);
}
#[test]
fn malformed() {
let k = [public(&key(1))];
let keys: Vec<&str> = k.iter().map(String::as_str).collect();
for t in ["", "abc", "a.b", "!!.!!", "e30.AAAA"] {
assert_eq!(
verify_token_with(t, &keys),
Err(LicenseError::Malformed),
"{t}"
);
}
}
#[test]
fn policy_keeps_existing_customer_contract() {
let p = policy();
assert_eq!(p.product, "anymd");
assert!(!p.require_product);
assert_eq!(p.accepted_plans, &["pro"]);
assert_eq!(p.public_keys, PRO_PUBLIC_KEYS);
assert_eq!(
PRO_PUBLIC_KEYS,
&["xO9jSvEq5nsVPMk9x62Egr0_n5WPpWCF8yCYmrwzH3Y"]
);
assert_eq!(p.env_var, "ANYMD_PRO_TOKEN");
assert_eq!(p.upgrade_url, PRO_URL);
let path = token_path().expect("config dir");
assert!(path.ends_with("anymd/pro-token"), "{path:?}");
let k = key(1);
assert!(verify_token_with(&token(&k, PRO), &[&public(&k)]).is_ok());
}
#[test]
fn buy_wiring() {
assert_eq!(
run_with(&policy_with(PRO_PUBLIC_KEYS, None), &["buy".into()]),
0
);
assert_eq!(policy().checkout_base, None);
for flag in ["--pack", "--qty", "--x"] {
assert_eq!(
run_with(&policy(), &["buy".into(), flag.into(), "1".into()]),
2
);
}
assert_eq!(run_with(&policy(), &[]), 2);
assert_eq!(run_with(&policy(), &["status".into(), "x".into()]), 2);
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "helper for buy_loopback_contract"]
fn buy_loopback_child() {
let base = std::env::var("ANYMD_TEST_CHECKOUT").unwrap();
let public_key = public(&key(1));
let keys = [&*public_key];
let p = policy_with(&keys, Some(&base));
let expected: i32 = std::env::var("ANYMD_TEST_EXIT").unwrap().parse().unwrap();
assert_eq!(
run_with(&p, &["buy".into(), "--no-browser".into(), "--json".into()]),
expected
);
assert_eq!(p.current().is_some(), expected == 0);
assert_eq!(p.token_path().unwrap().exists(), expected == 0);
}
#[cfg(target_os = "linux")]
#[test]
fn buy_loopback_contract() {
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::process::Command;
for (signer, expected) in [(1, 0), (2, 1)] {
let config = tempfile::tempdir().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let claim = serde_json::json!({
"claim_id": "local-contract",
"browser_url": format!("{base}/buy/anymd"),
"poll_url": format!("{base}/api/v1/claims/local-contract"),
"expires_at": 4_000_000_000_000_i64,
});
let paid = serde_json::json!({
"status": "paid",
"licence_tokens": [{"token": token(&key(signer), PRO)}],
});
let server = std::thread::spawn(move || {
for (method, path, response) in [
("POST", "/api/v1/claims", claim),
(
"GET",
"/api/v1/claims/local-contract",
serde_json::json!({"status": "pending"}),
),
("GET", "/api/v1/claims/local-contract", paid),
] {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.unwrap();
let mut reader = BufReader::new(&mut stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(line.trim(), format!("{method} {path} HTTP/1.1"));
let mut length = 0;
loop {
line.clear();
assert!(reader.read_line(&mut line).unwrap() > 0);
if line == "\r\n" {
break;
}
if let Some(value) =
line.to_ascii_lowercase().strip_prefix("content-length:")
{
length = value.trim().parse::<usize>().unwrap();
}
}
let mut body = vec![0; length];
reader.read_exact(&mut body).unwrap();
if method == "POST" {
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
serde_json::json!({"product": "anymd"})
);
}
let body = response.to_string();
write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
}
});
let output = Command::new("timeout")
.arg("30s")
.arg(std::env::current_exe().unwrap())
.args([
"--exact",
"pro::tests::buy_loopback_child",
"--ignored",
"--nocapture",
])
.env("XDG_CONFIG_HOME", config.path())
.env("HOME", config.path())
.env_remove(TOKEN_ENV)
.env("ANYMD_TEST_CHECKOUT", &base)
.env("ANYMD_TEST_EXIT", expected.to_string())
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
server.join().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("\"event\":\"claim\""));
assert!(stdout.contains(if expected == 0 {
"\"status\":\"paid\""
} else {
"\"status\":\"invalid\""
}));
assert!(!stdout.contains("\"token\":"));
}
}
#[test]
fn require_pro_message() {
assert!(require_pro_with("Video evidence", true).is_ok());
let required = require_pro_with("Cite-check", false).unwrap_err();
let message = required.to_string();
assert_eq!(
message,
"Cite-check is part of anymd Pro: US$29 once for an offline licence. Buy it with `anymd pro buy`. Learn more: https://sylphxai.github.io/anymd/pro"
);
let result = required_result(&required);
assert_eq!(result.is_error, Some(false));
assert_eq!(result.content.len(), 1);
assert_eq!(result.content[0].as_text().unwrap().text, message);
}
}