worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // Agent-first git hosting. |
| 2 | // |
| 3 | // Boot and operator commands only. Everything else lives in the modules below. |
| 4 | // The product name lives in `brand`; nothing here should spell it out. |
| 5 | |
| 6 | mod account; |
| 7 | mod api; |
| 8 | mod brand; |
| 9 | mod ci; |
| 10 | mod config; |
| 11 | mod control; |
| 12 | mod core; |
| 13 | mod error; |
| 14 | mod git; |
| 15 | mod http; |
| 16 | mod jobs; |
| 17 | mod mcp; |
| 18 | mod setup; |
| 19 | mod ssh; |
| 20 | mod store; |
| 21 | mod web; |
| 22 | |
| 23 | use account::token; |
| 24 | use account::Scope; |
| 25 | use anyhow::{bail, Context, Result}; |
| 26 | use config::{Config, Mode}; |
| 27 | use hyper::service::service_fn; |
| 28 | use hyper_util::rt::{TokioIo, TokioTimer}; |
| 29 | use std::sync::Arc; |
| 30 | use tokio::net::TcpListener; |
| 31 | |
| 32 | #[tokio::main(flavor = "multi_thread")] |
| 33 | async fn main() -> Result<()> { |
| 34 | match std::env::args().nth(1).as_deref() { |
| 35 | Some("hook") => return run_hook().await, |
| 36 | Some("jobs") => return run_jobs().await, |
| 37 | Some("setup") => return run_setup(), |
| 38 | Some("gc") => return run_maintenance(Job::Gc), |
| 39 | Some("sweep") => return run_maintenance(Job::Sweep), |
| 40 | Some("fsck") => return run_maintenance(Job::Fsck), |
| 41 | Some("token") => return mint_token(), |
| 42 | Some("key") => return add_key(), |
| 43 | Some("version") => { |
| 44 | println!("{} {}", brand::NAME, brand::build()); |
| 45 | return Ok(()); |
| 46 | } |
| 47 | Some(other) => bail!( |
| 48 | "unknown command {other:?} \ |
| 49 | (setup | token | key | jobs | gc | sweep | fsck | hook | version)" |
| 50 | ), |
| 51 | None => {} |
| 52 | } |
| 53 | |
| 54 | serve().await |
| 55 | } |
| 56 | |
| 57 | async fn serve() -> Result<()> { |
| 58 | let config = Config::from_env().context("read configuration")?; |
| 59 | eprintln!( |
| 60 | "[boot] {} {} mode={} data={}", |
| 61 | brand::NAME, |
| 62 | brand::VERSION, |
| 63 | config.mode.as_str(), |
| 64 | config.data_dir.display() |
| 65 | ); |
| 66 | |
| 67 | if config.mode == Mode::Control { |
| 68 | return serve_control(config).await; |
| 69 | } |
| 70 | |
| 71 | // Accepting a security-relevant setting and then ignoring it is worse than not |
| 72 | // accepting it: the operator believes hosted auth is enforcing revocation. |
| 73 | if config.me_url.is_some() { |
| 74 | bail!( |
| 75 | "{} is not implemented yet; unset it to use the local token file", |
| 76 | brand::env_name("ME_URL") |
| 77 | ); |
| 78 | } |
| 79 | if config.mode == Mode::Tenant { |
| 80 | eprintln!( |
| 81 | "[boot] tenant mode: callers are identified by a signed assertion from the \ |
| 82 | control plane ({} key(s) trusted)", |
| 83 | config.control_public_keys.len() |
| 84 | ); |
| 85 | } |
| 86 | if config.import_allow_private { |
| 87 | eprintln!("[boot] import: PRIVATE ADDRESSES ALLOWED — only safe on a trusted network"); |
| 88 | } |
| 89 | if !config.ci_enabled { |
| 90 | eprintln!("[boot] ci: disabled"); |
| 91 | } else { |
| 92 | // Said loudly because standalone CI is not a security boundary: anyone who |
| 93 | // can push can run code as this user. |
| 94 | eprintln!("[boot] ci: ENABLED — steps run as this user with rlimits but no container"); |
| 95 | } |
| 96 | |
| 97 | // Resolved once here rather than per request, where it was a synchronous |
| 98 | // fork+exec on a runtime worker for every clone and push. |
| 99 | git::exec::http_backend()?; |
| 100 | |
| 101 | let bind = config.bind; |
| 102 | let ssh_bind = config.ssh_bind; |
| 103 | let header_timeout = config.header_timeout; |
| 104 | let host_key_path = config.ssh_host_key.clone(); |
| 105 | let state = Arc::new(http::AppState::new(config)?); |
| 106 | |
| 107 | eprintln!( |
| 108 | "[boot] auth: {} token(s), {} ssh key(s)", |
| 109 | state.tokens().tokens.len(), |
| 110 | state.ssh_keys().keys.len() |
| 111 | ); |
| 112 | |
| 113 | if let Some(addr) = ssh_bind { |
| 114 | let host_key = ssh::load_host_key(&host_key_path)?; |
| 115 | let idle = state.config.ssh_idle_timeout; |
| 116 | let server = ssh::SshServer::new(Arc::clone(&state)); |
| 117 | tokio::spawn(async move { |
| 118 | if let Err(e) = server.run(addr, host_key, idle).await { |
| 119 | eprintln!("[ssh] server stopped: {e:#}"); |
| 120 | } |
| 121 | }); |
| 122 | } else { |
| 123 | eprintln!("[boot] ssh: disabled"); |
| 124 | } |
| 125 | |
| 126 | tokio::spawn(ci::serve(Arc::clone(&state))); |
| 127 | |
| 128 | let listener = TcpListener::bind(bind) |
| 129 | .await |
| 130 | .with_context(|| format!("bind {bind}"))?; |
| 131 | eprintln!("[boot] listening on http://{bind}"); |
| 132 | |
| 133 | loop { |
| 134 | let (stream, peer) = listener.accept().await.context("accept")?; |
| 135 | let state = Arc::clone(&state); |
| 136 | tokio::spawn(async move { |
| 137 | let io = TokioIo::new(stream); |
| 138 | let service = service_fn(move |req| http::handle(req, Arc::clone(&state), peer)); |
| 139 | if let Err(e) = hyper::server::conn::http1::Builder::new() |
| 140 | // hyper panics at runtime if a timeout is set without a timer. |
| 141 | .timer(TokioTimer::new()) |
| 142 | .header_read_timeout(header_timeout) |
| 143 | .serve_connection(io, service) |
| 144 | .await |
| 145 | { |
| 146 | // A client hanging up mid-clone is ordinary, not a fault. |
| 147 | let message = e.to_string(); |
| 148 | if !message.contains("closed") && !message.contains("reset") { |
| 149 | eprintln!("[conn] {message}"); |
| 150 | } |
| 151 | } |
| 152 | }); |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /// `<binary> setup --standalone|--isolated` — install onto this host. |
| 157 | fn run_setup() -> Result<()> { |
| 158 | let args: Vec<String> = std::env::args().skip(2).collect(); |
| 159 | let options = setup::Options::from_args(&args)?; |
| 160 | |
| 161 | let checks = setup::preflight(options.shape); |
| 162 | let missing: Vec<&(String, bool, String)> = checks.iter().filter(|(_, ok, _)| !ok).collect(); |
| 163 | |
| 164 | for (name, ok, detail) in &checks { |
| 165 | eprintln!( |
| 166 | "[setup] {} {name}: {detail}", |
| 167 | if *ok { "ok " } else { "MISS" } |
| 168 | ); |
| 169 | } |
| 170 | if !missing.is_empty() && !options.dry_run { |
| 171 | bail!( |
| 172 | "missing prerequisites: {}. Install them, or re-run with --dry-run to see \ |
| 173 | what would be written.", |
| 174 | missing |
| 175 | .iter() |
| 176 | .map(|(n, _, _)| n.as_str()) |
| 177 | .collect::<Vec<_>>() |
| 178 | .join(", ") |
| 179 | ); |
| 180 | } |
| 181 | eprintln!(); |
| 182 | |
| 183 | setup::apply(&options) |
| 184 | } |
| 185 | |
| 186 | /// The control plane: accounts, provisioning, and proxying to tenants. |
| 187 | /// |
| 188 | /// Holds no repositories. Every data-plane request is forwarded to the account's own |
| 189 | /// container with a freshly signed, single-use assertion. |
| 190 | async fn serve_control(config: Config) -> Result<()> { |
| 191 | use control::provision::{IncusProvisioner, Provisioner}; |
| 192 | |
| 193 | let accounts = control::accounts::AccountStore::open(config.accounts_dir())?; |
| 194 | let signer = load_control_key(&config)?; |
| 195 | eprintln!( |
| 196 | "[control] tenants need {}={}", |
| 197 | brand::env_name("CONTROL_PUBLIC_KEYS"), |
| 198 | signer.public_key() |
| 199 | ); |
| 200 | |
| 201 | let provisioner: Arc<dyn Provisioner> = Arc::new(IncusProvisioner::new( |
| 202 | config.incus_image.clone(), |
| 203 | config.incus_network.clone(), |
| 204 | config.tenant_port, |
| 205 | config.tenant_binary.clone(), |
| 206 | signer.public_key(), |
| 207 | std::env::var(brand::env_name("PUBLIC_URL")).ok(), |
| 208 | std::env::var(brand::env_name("PUBLIC_SSH")).ok(), |
| 209 | )); |
| 210 | |
| 211 | tokio::spawn(control::reconcile_forever( |
| 212 | accounts.clone(), |
| 213 | Arc::clone(&provisioner), |
| 214 | )); |
| 215 | |
| 216 | control::serve(config, accounts, signer, provisioner).await |
| 217 | } |
| 218 | |
| 219 | /// Load the control plane's signing key, generating one on first boot. |
| 220 | /// |
| 221 | /// The private half never leaves this host: a tenant runs the account's own CI, so |
| 222 | /// anything a tenant holds must be forgery-proof. |
| 223 | fn load_control_key(config: &Config) -> Result<account::assertion::Signer_> { |
| 224 | use base64::Engine; |
| 225 | |
| 226 | let path = &config.control_key_file; |
| 227 | if path.exists() { |
| 228 | let encoded = |
| 229 | std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; |
| 230 | let bytes = base64::engine::general_purpose::STANDARD |
| 231 | .decode(encoded.trim()) |
| 232 | .context("control key is not valid base64")?; |
| 233 | let seed: [u8; 32] = bytes |
| 234 | .try_into() |
| 235 | .map_err(|_| anyhow::anyhow!("control key must be 32 bytes"))?; |
| 236 | return Ok(account::assertion::Signer_::from_seed(&seed)); |
| 237 | } |
| 238 | |
| 239 | let mut seed = [0u8; 32]; |
| 240 | getrandom::fill(&mut seed).map_err(|e| anyhow::anyhow!("read OS entropy: {e}"))?; |
| 241 | account::token::write_private( |
| 242 | path, |
| 243 | base64::engine::general_purpose::STANDARD |
| 244 | .encode(seed) |
| 245 | .as_bytes(), |
| 246 | )?; |
| 247 | eprintln!("[control] generated a signing key at {}", path.display()); |
| 248 | Ok(account::assertion::Signer_::from_seed(&seed)) |
| 249 | } |
| 250 | |
| 251 | /// `post-receive` — queue a CI run for each updated ref. |
| 252 | /// |
| 253 | /// git feeds `<old> <new> <ref>` per line on stdin. This never fails the push: a |
| 254 | /// broken queue must not stop code being accepted. |
| 255 | async fn enqueue_runs() -> Result<()> { |
| 256 | use std::io::Read; |
| 257 | |
| 258 | let config = Config::from_env().context("read configuration")?; |
| 259 | if !config.ci_enabled { |
| 260 | return Ok(()); |
| 261 | } |
| 262 | |
| 263 | let git_dir = std::env::var("GIT_DIR").unwrap_or_else(|_| ".".into()); |
| 264 | let Some((account, repo)) = account_and_repo(&config, &git_dir) else { |
| 265 | return Ok(()); |
| 266 | }; |
| 267 | |
| 268 | let mut stdin = String::new(); |
| 269 | if std::io::stdin().read_to_string(&mut stdin).is_err() { |
| 270 | return Ok(()); |
| 271 | } |
| 272 | |
| 273 | let runs = match ci::run::RunStore::open(config.runs_dir()) { |
| 274 | Ok(runs) => runs, |
| 275 | Err(e) => { |
| 276 | eprintln!("{}", brand::say(format_args!("could not queue a run: {e}"))); |
| 277 | return Ok(()); |
| 278 | } |
| 279 | }; |
| 280 | |
| 281 | for line in stdin.lines() { |
| 282 | let fields: Vec<&str> = line.split_whitespace().collect(); |
| 283 | let [_old, new, git_ref] = fields.as_slice() else { |
| 284 | continue; |
| 285 | }; |
| 286 | // A deletion has nothing to build. |
| 287 | if git::store::is_null_oid(new) { |
| 288 | continue; |
| 289 | } |
| 290 | |
| 291 | let mut run = ci::run::Run::queued(account.as_str(), repo.as_str(), git_ref, new); |
| 292 | run.number = runs.next_number(&account, &repo); |
| 293 | match runs.put(&account, &repo, &run) { |
| 294 | Ok(()) => println!("{}", brand::say(format_args!("queued run {}", run.id))), |
| 295 | Err(e) => eprintln!("{}", brand::say(format_args!("could not queue a run: {e}"))), |
| 296 | } |
| 297 | } |
| 298 | Ok(()) |
| 299 | } |
| 300 | |
| 301 | /// Recover `(account, repo)` from a bare repository path under the git root. |
| 302 | fn account_and_repo( |
| 303 | config: &Config, |
| 304 | git_dir: &str, |
| 305 | ) -> Option<(git::validate::Name, git::validate::Name)> { |
| 306 | let path = std::path::Path::new(git_dir).canonicalize().ok()?; |
| 307 | let root = config.data_dir.join("git").canonicalize().ok()?; |
| 308 | let rest = path.strip_prefix(&root).ok()?; |
| 309 | |
| 310 | let mut parts = rest.components(); |
| 311 | let account = parts.next()?.as_os_str().to_str()?; |
| 312 | let repo = parts.next()?.as_os_str().to_str()?.strip_suffix(".git")?; |
| 313 | |
| 314 | Some(( |
| 315 | git::validate::name(account).ok()?, |
| 316 | git::validate::name(repo).ok()?, |
| 317 | )) |
| 318 | } |
| 319 | |
| 320 | /// One-shot maintenance, for an operator or a timer. |
| 321 | #[derive(Debug, Clone, Copy)] |
| 322 | enum Job { |
| 323 | Gc, |
| 324 | Sweep, |
| 325 | Fsck, |
| 326 | } |
| 327 | |
| 328 | /// `<binary> gc | sweep | fsck [--repair]` |
| 329 | fn run_maintenance(job: Job) -> Result<()> { |
| 330 | let config = Config::from_env().context("read configuration")?; |
| 331 | |
| 332 | match job { |
| 333 | Job::Gc => { |
| 334 | let outcome = jobs::gc::run(&config); |
| 335 | eprintln!( |
| 336 | "[gc] repacked {} repositories, reclaimed {} bytes", |
| 337 | outcome.packed, outcome.reclaimed |
| 338 | ); |
| 339 | } |
| 340 | Job::Sweep => { |
| 341 | let outcome = jobs::sweep::run(&config); |
| 342 | eprintln!( |
| 343 | "[sweep] removed {} deleted repositories, reclaimed {} bytes", |
| 344 | outcome.removed, outcome.bytes |
| 345 | ); |
| 346 | } |
| 347 | Job::Fsck => { |
| 348 | let repair = std::env::args().any(|a| a == "--repair"); |
| 349 | let outcome = jobs::fsck::run(&config, repair); |
| 350 | println!("{}", serde_json::to_string_pretty(&outcome)?); |
| 351 | eprintln!( |
| 352 | "[fsck] checked {} repositories, {} orphan(s), {} repaired", |
| 353 | outcome.checked, |
| 354 | outcome.orphans.len(), |
| 355 | outcome.repaired |
| 356 | ); |
| 357 | // A non-zero exit lets a timer or a health check notice. |
| 358 | if !outcome.orphans.is_empty() && !repair { |
| 359 | std::process::exit(1); |
| 360 | } |
| 361 | } |
| 362 | } |
| 363 | Ok(()) |
| 364 | } |
| 365 | |
| 366 | /// `<binary> jobs` — the maintenance daemon, for its own service unit. |
| 367 | async fn run_jobs() -> Result<()> { |
| 368 | let config = Config::from_env().context("read configuration")?; |
| 369 | eprintln!( |
| 370 | "[boot] {} {} jobs data={}", |
| 371 | brand::NAME, |
| 372 | brand::VERSION, |
| 373 | config.data_dir.display() |
| 374 | ); |
| 375 | jobs::serve(Arc::new(config)).await; |
| 376 | Ok(()) |
| 377 | } |
| 378 | |
| 379 | /// `<binary> hook <name> [args...]` — invoked by the installed git hooks. |
| 380 | /// |
| 381 | /// The hooks carry no policy; each execs back into this binary so the rule has one |
| 382 | /// implementation shared with the API path. |
| 383 | async fn run_hook() -> Result<()> { |
| 384 | let name = std::env::args() |
| 385 | .nth(2) |
| 386 | .with_context(|| format!("usage: {} hook <name>", brand::NAME))?; |
| 387 | |
| 388 | match name.as_str() { |
| 389 | "update" => { |
| 390 | // git calls: update <ref> <old> <new> |
| 391 | let args: Vec<String> = std::env::args().skip(3).collect(); |
| 392 | let [ref_name, old, new] = args.as_slice() else { |
| 393 | bail!("update hook expects <ref> <old> <new>"); |
| 394 | }; |
| 395 | |
| 396 | let git_dir = std::env::var("GIT_DIR").unwrap_or_else(|_| ".".into()); |
| 397 | |
| 398 | match git::store::check_ref_move(std::path::Path::new(&git_dir), ref_name, old, new) { |
| 399 | Ok(()) => Ok(()), |
| 400 | Err(e) => { |
| 401 | // Printed to the pusher's terminal by receive-pack. |
| 402 | eprintln!("{}", brand::say(e.detail_for_user())); |
| 403 | std::process::exit(1); |
| 404 | } |
| 405 | } |
| 406 | } |
| 407 | "post-receive" => enqueue_runs().await, |
| 408 | other => bail!("unknown hook {other:?}"), |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | /// `<binary> token <account> [--name n] [--scopes a,b] [--repos a,b] [--days n]` |
| 413 | fn mint_token() -> Result<()> { |
| 414 | let config = Config::from_env().context("read configuration")?; |
| 415 | let args: Vec<String> = std::env::args().skip(2).collect(); |
| 416 | |
| 417 | let account = args |
| 418 | .first() |
| 419 | .filter(|a| !a.starts_with("--")) |
| 420 | .with_context(|| { |
| 421 | format!( |
| 422 | "usage: {} token <account> [--scopes admin] [--days 90]", |
| 423 | brand::NAME |
| 424 | ) |
| 425 | })? |
| 426 | .clone(); |
| 427 | let account = git::validate::name(&account)?; |
| 428 | |
| 429 | let name = flag(&args, "--name").unwrap_or_else(|| "default".into()); |
| 430 | let scopes = parse_scopes(&flag(&args, "--scopes").unwrap_or_else(|| "admin".into()))?; |
| 431 | let repos = flag_repos(&args)?; |
| 432 | |
| 433 | // A forever-token is total account compromise, so expiry is opt-out and loud. |
| 434 | let expires_at = if args.iter().any(|a| a == "--no-expiry") { |
| 435 | eprintln!("[token] warning: this token never expires"); |
| 436 | None |
| 437 | } else { |
| 438 | let days = flag(&args, "--days") |
| 439 | .map(|v| v.parse::<u64>().context("--days must be a number")) |
| 440 | .transpose()? |
| 441 | .unwrap_or(config.token_ttl_days); |
| 442 | Some(token::now_secs() + days * 24 * 60 * 60) |
| 443 | }; |
| 444 | |
| 445 | let (secret, record) = token::mint(account.as_str(), &name, scopes, repos, expires_at); |
| 446 | |
| 447 | let path = config.tokens_file(); |
| 448 | let mut file = token::TokenFile::load(&path)?; |
| 449 | file.tokens.push(record); |
| 450 | file.save(&path)?; |
| 451 | |
| 452 | eprintln!("[token] wrote {}", path.display()); |
| 453 | eprintln!("[token] shown once and not recoverable:"); |
| 454 | println!("{secret}"); |
| 455 | Ok(()) |
| 456 | } |
| 457 | |
| 458 | /// `<binary> key <account> <path-to-pubkey> [--title t] [--read-only] [--repos a,b]` |
| 459 | fn add_key() -> Result<()> { |
| 460 | let config = Config::from_env().context("read configuration")?; |
| 461 | let args: Vec<String> = std::env::args().skip(2).collect(); |
| 462 | |
| 463 | let account = args |
| 464 | .first() |
| 465 | .with_context(|| format!("usage: {} key <account> <path-to-public-key>", brand::NAME))?; |
| 466 | let account = git::validate::name(account)?; |
| 467 | |
| 468 | let key_path = args |
| 469 | .get(1) |
| 470 | .filter(|a| !a.starts_with("--")) |
| 471 | .with_context(|| format!("usage: {} key <account> <path-to-public-key>", brand::NAME))?; |
| 472 | let line = std::fs::read_to_string(key_path).with_context(|| format!("read {key_path}"))?; |
| 473 | |
| 474 | let (algorithm, blob) = account::key::parse_authorized_key(&line)?; |
| 475 | |
| 476 | let repos = flag_repos(&args)?; |
| 477 | |
| 478 | let record = account::KeyRecord { |
| 479 | id: uuid::Uuid::new_v4().simple().to_string(), |
| 480 | account: account.as_str().to_string(), |
| 481 | title: flag(&args, "--title").unwrap_or_else(|| "cli".into()), |
| 482 | public_key: account::key::encode_public_key(&algorithm, &blob), |
| 483 | fingerprint: account::key::fingerprint(&blob), |
| 484 | read_only: args.iter().any(|a| a == "--read-only"), |
| 485 | repos, |
| 486 | created_at: token::now_secs(), |
| 487 | last_used_at: None, |
| 488 | }; |
| 489 | |
| 490 | let path = config.keys_file(); |
| 491 | let mut file = account::KeyFile::load(&path)?; |
| 492 | if file.keys.iter().any(|k| k.public_key == record.public_key) { |
| 493 | bail!("that public key is already registered"); |
| 494 | } |
| 495 | eprintln!("[key] {} -> {}", record.fingerprint, record.account); |
| 496 | file.keys.push(record); |
| 497 | file.save(&path)?; |
| 498 | eprintln!("[key] wrote {}", path.display()); |
| 499 | Ok(()) |
| 500 | } |
| 501 | |
| 502 | /// A comma-separated `--repos` list, validated into canonical names. |
| 503 | fn flag_repos(args: &[String]) -> Result<Vec<String>> { |
| 504 | let Some(raw) = flag(args, "--repos") else { |
| 505 | return Ok(Vec::new()); |
| 506 | }; |
| 507 | raw.split(',') |
| 508 | .filter(|s| !s.is_empty()) |
| 509 | .map(|s| Ok(git::validate::name(s)?.as_str().to_string())) |
| 510 | .collect() |
| 511 | } |
| 512 | |
| 513 | fn flag(args: &[String], name: &str) -> Option<String> { |
| 514 | args.iter() |
| 515 | .position(|a| a == name) |
| 516 | .and_then(|i| args.get(i + 1)) |
| 517 | .cloned() |
| 518 | } |
| 519 | |
| 520 | fn parse_scopes(raw: &str) -> Result<Vec<Scope>> { |
| 521 | let scopes = raw |
| 522 | .split(',') |
| 523 | .filter(|s| !s.is_empty()) |
| 524 | .map(|s| match s.trim() { |
| 525 | "repo:read" => Ok(Scope::RepoRead), |
| 526 | "repo:write" => Ok(Scope::RepoWrite), |
| 527 | "admin" => Ok(Scope::Admin), |
| 528 | "ci" => Ok(Scope::Ci), |
| 529 | other => bail!("unknown scope {other:?} (repo:read | repo:write | admin | ci)"), |
| 530 | }) |
| 531 | .collect::<Result<Vec<_>>>()?; |
| 532 | |
| 533 | if scopes.is_empty() { |
| 534 | bail!("at least one scope is required"); |
| 535 | } |
| 536 | Ok(scopes) |
| 537 | } |