g1t/services/identity/src/lib.rs

553 lines20,526 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

API and MCP server, Rust identity service, registration, site redesign1//! The identity service: accounts, sessions, SSH keys and access tokens.
2//!
3//! Reached only through service bindings; see `g1t_contracts::identity` for
4//! the methods and their arguments.
5
6mod crypto;
Device sign-in replaces registering and minting tokens over the API7mod device;
Email verification, password reset, and Git for AI scale positioning8mod email;
OAuth 2.1 sign-in for MCP clients and other applications9mod oauth;
Agents as a team: lifecycle, merge queue, billing and a new shell10mod tokens;
Workspaces own repositories11mod workspaces;
API and MCP server, Rust identity service, registration, site redesign12
13use g1t_contracts::identity::*;
RFC 3339 timestamps in identity and repos14use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
API and MCP server, Rust identity service, registration, site redesign15use g1t_contracts::{FailureCode, Outcome, User, Viewer, is_valid_namespace, new_id};
16use g1t_kit::{args, now_ms, reply, rpc_method};
17use serde::Deserialize;
Agents as a team: lifecycle, merge queue, billing and a new shell18use tokens::TOKEN_PREFIX;
API and MCP server, Rust identity service, registration, site redesign19use worker::wasm_bindgen::JsValue;
20use worker::{Context, D1Database, Env, Request, Response, Result, event};
21
RFC 3339 timestamps in identity and repos22const SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
23const VERIFY_TTL_SECONDS: u64 = 24 * 60 * 60;
24const RESET_TTL_SECONDS: u64 = 60 * 60;
API and MCP server, Rust identity service, registration, site redesign25const MIN_PASSWORD_LENGTH: usize = 10;
Email verification, password reset, and Git for AI scale positioning26const PASSWORD_TOO_SHORT: &str = "Use a password of at least 10 characters.";
27
28/// A user as selected from the database; `verified` arrives as 0 or 1.
29#[derive(Deserialize)]
30struct Account {
31 id: String,
32 username: String,
33 verified: u8,
34}
35
36impl From<Account> for User {
37 fn from(row: Account) -> Self {
38 User {
39 id: row.id,
40 username: row.username,
41 verified: row.verified != 0,
Agents as a team: lifecycle, merge queue, billing and a new shell42 ..User::default()
Email verification, password reset, and Git for AI scale positioning43 }
44 }
45}
API and MCP server, Rust identity service, registration, site redesign46
47#[derive(Deserialize)]
48struct UserRow {
49 id: String,
50 username: String,
51 password_hash: String,
Email verification, password reset, and Git for AI scale positioning52 verified: u8,
API and MCP server, Rust identity service, registration, site redesign53}
54
Email verification, password reset, and Git for AI scale positioning55/// The owner of an emailed token.
API and MCP server, Rust identity service, registration, site redesign56#[derive(Deserialize)]
Email verification, password reset, and Git for AI scale positioning57struct TokenOwner {
58 id: String,
59 username: String,
60 email: Option<String>,
61}
62
63#[derive(Deserialize)]
API and MCP server, Rust identity service, registration, site redesign64struct KeyRow {
65 id: String,
66 title: String,
67 fingerprint: String,
RFC 3339 timestamps in identity and repos68 created_at: String,
API and MCP server, Rust identity service, registration, site redesign69}
70
71impl From<KeyRow> for SshKey {
72 fn from(row: KeyRow) -> Self {
73 SshKey {
74 id: row.id,
75 title: row.title,
76 fingerprint: row.fingerprint,
RFC 3339 timestamps in identity and repos77 created_at: row.created_at,
API and MCP server, Rust identity service, registration, site redesign78 }
79 }
80}
81
82struct Identity {
83 db: D1Database,
Email verification, password reset, and Git for AI scale positioning84 env: Env,
API and MCP server, Rust identity service, registration, site redesign85}
86
87impl Identity {
Workspaces own repositories88 /// Runs a query that returns at most one user, for showing to others:
89 /// without their workspaces.
90 async fn find_public_user(&self, sql: &str, param: &str) -> Result<Viewer> {
Email verification, password reset, and Git for AI scale positioning91 Ok(self
92 .db
API and MCP server, Rust identity service, registration, site redesign93 .prepare(sql)
94 .bind(&[JsValue::from(param)])?
Email verification, password reset, and Git for AI scale positioning95 .first::<Account>(None)
96 .await?
97 .map(User::from))
98 }
99
Workspaces own repositories100 /// Attaches the workspaces a user belongs to, so that any service can
101 /// authorize them without asking again.
102 async fn with_workspaces(&self, user: Viewer) -> Result<Viewer> {
103 let Some(mut user) = user else {
104 return Ok(None);
105 };
106 user.workspaces = self.memberships(&user.id).await?;
107 Ok(Some(user))
108 }
109
110 /// Runs a query that resolves credentials to at most one user.
111 async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> {
112 let user = self.find_public_user(sql, param).await?;
113 self.with_workspaces(user).await
114 }
115
Email verification, password reset, and Git for AI scale positioning116 /// Stores a one-time token of `kind` for the user and returns it.
RFC 3339 timestamps in identity and repos117 async fn issue_email_token(&self, user_id: &str, kind: &str, ttl: u64) -> Result<String> {
Email verification, password reset, and Git for AI scale positioning118 let token = crypto::random_hex(32);
119 self.db
RFC 3339 timestamps in identity and repos120 .prepare(format!(
Email verification, password reset, and Git for AI scale positioning121 "INSERT INTO email_tokens (id, user_id, kind, expires_at)
RFC 3339 timestamps in identity and repos122 VALUES (?, ?, ?, {})",
123 sql_after(ttl)
124 ))
Email verification, password reset, and Git for AI scale positioning125 .bind(&[
126 crypto::sha256_hex(&token).into(),
127 user_id.into(),
128 kind.into(),
129 ])?
130 .run()
131 .await?;
132 Ok(token)
API and MCP server, Rust identity service, registration, site redesign133 }
134
Email verification, password reset, and Git for AI scale positioning135 /// Consumes a token of `kind`, returning its owner if it was valid.
136 async fn redeem_email_token(&self, token: &str, kind: &str) -> Result<Option<TokenOwner>> {
137 let id = crypto::sha256_hex(token);
138 let owner = self
139 .db
RFC 3339 timestamps in identity and repos140 .prepare(format!(
Email verification, password reset, and Git for AI scale positioning141 "SELECT users.id, users.username, users.email FROM email_tokens
142 JOIN users ON users.id = email_tokens.user_id
143 WHERE email_tokens.id = ? AND email_tokens.kind = ?
RFC 3339 timestamps in identity and repos144 AND email_tokens.expires_at > {SQL_NOW}"
145 ))
Email verification, password reset, and Git for AI scale positioning146 .bind(&[id.as_str().into(), kind.into()])?
147 .first::<TokenOwner>(None)
148 .await?;
149 if let Some(owner) = &owner {
150 // Every outstanding token of this kind dies with the one used.
151 self.db
152 .prepare("DELETE FROM email_tokens WHERE user_id = ? AND kind = ?")
153 .bind(&[owner.id.as_str().into(), kind.into()])?
154 .run()
155 .await?;
156 }
157 Ok(owner)
158 }
159
160 async fn send_verification(&self, user: &User, email: &str) -> Result<()> {
161 let token = self
162 .issue_email_token(&user.id, "verify", VERIFY_TTL_SECONDS)
163 .await?;
164 email::send_verification(&self.env, email, &user.username, &token).await
165 }
166
167 async fn resend_verification(&self, a: UserArgs) -> Result<Outcome<bool>> {
168 let row = self
169 .db
170 .prepare(
171 "SELECT id, username, email FROM users WHERE id = ? AND email_verified_at IS NULL",
172 )
173 .bind(&[a.user.id.as_str().into()])?
174 .first::<TokenOwner>(None)
175 .await?;
176 let Some(TokenOwner {
177 email: Some(email), ..
178 }) = row
179 else {
180 return Ok(Outcome::fail(
181 FailureCode::Conflict,
182 "This account's email is already confirmed.",
183 ));
184 };
185 self.send_verification(&a.user, &email).await?;
186 Ok(Outcome::Ok(true))
187 }
188
189 async fn verify_email(&self, a: EmailTokenArgs) -> Result<Outcome<User>> {
190 let Some(owner) = self.redeem_email_token(&a.token, "verify").await? else {
191 return Ok(Outcome::fail(
192 FailureCode::Invalid,
193 "This confirmation link is not valid or has expired.",
194 ));
195 };
196 self.db
RFC 3339 timestamps in identity and repos197 .prepare(format!(
198 "UPDATE users SET email_verified_at = {SQL_NOW} WHERE id = ?"
199 ))
Email verification, password reset, and Git for AI scale positioning200 .bind(&[owner.id.as_str().into()])?
201 .run()
202 .await?;
203 Ok(Outcome::Ok(User {
204 id: owner.id,
205 username: owner.username,
206 verified: true,
Workspaces own repositories207 ..User::default()
Email verification, password reset, and Git for AI scale positioning208 }))
209 }
210
211 async fn request_password_reset(&self, a: EmailArgs) -> Result<bool> {
212 let row = self
213 .db
214 .prepare("SELECT id, username, email FROM users WHERE email = ?")
215 .bind(&[a.email.trim().to_lowercase().into()])?
216 .first::<TokenOwner>(None)
217 .await?;
218 if let Some(TokenOwner {
219 id,
220 username,
221 email: Some(email),
222 }) = row
223 {
224 let token = self
225 .issue_email_token(&id, "reset", RESET_TTL_SECONDS)
226 .await?;
227 email::send_password_reset(&self.env, &email, &username, &token).await?;
228 }
229 // The same answer either way, so addresses cannot be probed.
230 Ok(true)
231 }
232
233 async fn reset_password(&self, a: ResetPasswordArgs) -> Result<Outcome<User>> {
234 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
235 return Ok(Outcome::fail(FailureCode::Invalid, PASSWORD_TOO_SHORT));
236 }
237 let Some(owner) = self.redeem_email_token(&a.token, "reset").await? else {
238 return Ok(Outcome::fail(
239 FailureCode::Invalid,
240 "This reset link is not valid or has expired.",
241 ));
242 };
243 // Following an emailed link also proves the address.
244 self.db
RFC 3339 timestamps in identity and repos245 .prepare(format!(
Email verification, password reset, and Git for AI scale positioning246 "UPDATE users SET password_hash = ?,
RFC 3339 timestamps in identity and repos247 email_verified_at = COALESCE(email_verified_at, {SQL_NOW})
248 WHERE id = ?"
249 ))
Email verification, password reset, and Git for AI scale positioning250 .bind(&[
251 crypto::hash_password(&a.password).into(),
252 owner.id.as_str().into(),
253 ])?
254 .run()
255 .await?;
256 // Anyone signed in with the old password is signed out.
257 self.db
258 .prepare("DELETE FROM sessions WHERE user_id = ?")
259 .bind(&[owner.id.as_str().into()])?
260 .run()
261 .await?;
262 Ok(Outcome::Ok(User {
263 id: owner.id,
264 username: owner.username,
265 verified: true,
Workspaces own repositories266 ..User::default()
Email verification, password reset, and Git for AI scale positioning267 }))
268 }
269
API and MCP server, Rust identity service, registration, site redesign270 async fn user_for_password(&self, username: &str, password: &str) -> Result<Viewer> {
271 let row = self
272 .db
Email verification, password reset, and Git for AI scale positioning273 .prepare("SELECT id, username, password_hash, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?")
API and MCP server, Rust identity service, registration, site redesign274 .bind(&[JsValue::from(username.to_lowercase())])?
275 .first::<UserRow>(None)
276 .await?;
Workspaces own repositories277 let user = row
API and MCP server, Rust identity service, registration, site redesign278 .filter(|row| crypto::verify_password(password, &row.password_hash))
279 .map(|row| User {
280 id: row.id,
281 username: row.username,
Email verification, password reset, and Git for AI scale positioning282 verified: row.verified != 0,
Workspaces own repositories283 ..User::default()
284 });
285 self.with_workspaces(user).await
API and MCP server, Rust identity service, registration, site redesign286 }
287
288 async fn register(&self, a: RegisterArgs) -> Result<Outcome<SignedIn>> {
289 let username = a.username.trim().to_lowercase();
290 let email = a.email.trim().to_lowercase();
291 let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message));
292 if !is_valid_namespace(&username) {
293 return invalid(
294 "Usernames use lowercase letters, digits and single hyphens, up to 39 characters.",
295 );
296 }
297 let well_formed_email = email
298 .split_once('@')
299 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
300 && !email.contains(char::is_whitespace);
301 if !well_formed_email {
302 return invalid("Enter a valid email address.");
303 }
304 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
Email verification, password reset, and Git for AI scale positioning305 return invalid(PASSWORD_TOO_SHORT);
API and MCP server, Rust identity service, registration, site redesign306 }
307 let taken = self
308 .db
Agents as a team: lifecycle, merge queue, billing and a new shell309 // Usernames and workspaces share one namespace, so that a name
310 // means the same thing wherever it appears.
311 .prepare(
312 "SELECT username FROM users WHERE username = ? OR email = ?
313 UNION ALL SELECT slug FROM workspaces WHERE slug = ?",
314 )
315 .bind(&[
316 username.as_str().into(),
317 email.as_str().into(),
318 username.as_str().into(),
319 ])?
API and MCP server, Rust identity service, registration, site redesign320 .first::<serde_json::Value>(None)
321 .await?;
322 if taken.is_some() {
323 return Ok(Outcome::fail(
324 FailureCode::Conflict,
325 "That username or email is already registered.",
326 ));
327 }
328 let user = User {
329 id: new_id("usr", now_ms()),
330 username,
Workspaces own repositories331 ..User::default()
API and MCP server, Rust identity service, registration, site redesign332 };
333 self.db
334 .prepare("INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)")
335 .bind(&[
336 user.id.as_str().into(),
337 user.username.as_str().into(),
Email verification, password reset, and Git for AI scale positioning338 email.as_str().into(),
API and MCP server, Rust identity service, registration, site redesign339 crypto::hash_password(&a.password).into(),
340 ])?
341 .run()
342 .await?;
Email verification, password reset, and Git for AI scale positioning343 // The account exists either way; the email can be sent again later.
344 if let Err(error) = self.send_verification(&user, &email).await {
345 worker::console_error!("verification email failed: {error}");
346 }
API and MCP server, Rust identity service, registration, site redesign347 self.start_session(user).await
348 }
349
350 async fn sign_in(&self, a: SignInArgs) -> Result<Outcome<SignedIn>> {
351 let Some(user) = self.user_for_password(&a.username, &a.password).await? else {
352 return Ok(Outcome::fail(
353 FailureCode::Unauthenticated,
354 "Incorrect username or password.",
355 ));
356 };
357 self.start_session(user).await
358 }
359
360 async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> {
361 let session_token = crypto::random_hex(32);
362 self.db
RFC 3339 timestamps in identity and repos363 .prepare(format!(
364 "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, {})",
365 sql_after(SESSION_TTL_SECONDS)
366 ))
API and MCP server, Rust identity service, registration, site redesign367 .bind(&[
368 crypto::sha256_hex(&session_token).into(),
369 user.id.as_str().into(),
370 ])?
371 .run()
372 .await?;
373 Ok(Outcome::Ok(SignedIn {
374 user,
375 session_token,
376 }))
377 }
378
379 async fn sign_out(&self, a: SessionArgs) -> Result<()> {
380 self.db
381 .prepare("DELETE FROM sessions WHERE id = ?")
382 .bind(&[crypto::sha256_hex(&a.session_token).into()])?
383 .run()
384 .await?;
385 Ok(())
386 }
387
388 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
389 self.find_user(
RFC 3339 timestamps in identity and repos390 &format!(
391 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified
392 FROM sessions JOIN users ON users.id = sessions.user_id
393 WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW}"
394 ),
API and MCP server, Rust identity service, registration, site redesign395 &crypto::sha256_hex(&a.session_token),
396 )
397 .await
398 }
399
400 async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> {
401 // Like GitHub, a token alone identifies its user.
402 if a.secret.starts_with(TOKEN_PREFIX) {
403 self.user_for_access_token(&a.secret).await
404 } else {
405 self.user_for_password(&a.username, &a.secret).await
406 }
407 }
408
409 async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> {
410 self.find_user(
Email verification, password reset, and Git for AI scale positioning411 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM ssh_keys
API and MCP server, Rust identity service, registration, site redesign412 JOIN users ON users.id = ssh_keys.user_id
413 WHERE fingerprint = ?",
414 &a.fingerprint,
415 )
416 .await
417 }
418
419 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
Workspaces own repositories420 self.find_public_user(
Email verification, password reset, and Git for AI scale positioning421 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?",
API and MCP server, Rust identity service, registration, site redesign422 &a.username.to_lowercase(),
423 )
424 .await
425 }
426
427 async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> {
428 let rows = self
429 .db
430 .prepare("SELECT id, title, fingerprint, created_at FROM ssh_keys WHERE user_id = ? ORDER BY id")
431 .bind(&[a.user.id.into()])?
432 .all()
433 .await?
434 .results::<KeyRow>()?;
435 Ok(rows.into_iter().map(SshKey::from).collect())
436 }
437
438 async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> {
439 let Some(key) = crypto::parse_ssh_key(&a.public_key) else {
440 return Ok(Outcome::fail(
441 FailureCode::Invalid,
442 "That is not a valid OpenSSH public key.",
443 ));
444 };
445 let taken = self
446 .db
447 .prepare("SELECT id FROM ssh_keys WHERE fingerprint = ?")
448 .bind(&[key.fingerprint.as_str().into()])?
449 .first::<serde_json::Value>(None)
450 .await?;
451 if taken.is_some() {
452 return Ok(Outcome::fail(
453 FailureCode::Conflict,
454 "That key is already registered.",
455 ));
456 }
457 let now = now_ms();
458 let title = [a.title.trim(), key.comment.as_str(), "SSH key"]
459 .into_iter()
460 .find(|candidate| !candidate.is_empty())
461 .unwrap_or_default()
462 .to_owned();
463 let row = KeyRow {
464 id: new_id("key", now),
465 title,
466 fingerprint: key.fingerprint,
RFC 3339 timestamps in identity and repos467 created_at: rfc3339(now),
API and MCP server, Rust identity service, registration, site redesign468 };
469 self.db
470 .prepare(
471 "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at)
472 VALUES (?, ?, ?, ?, ?, ?)",
473 )
474 .bind(&[
475 row.id.as_str().into(),
476 a.user.id.into(),
477 row.title.as_str().into(),
478 key.public_key.into(),
479 row.fingerprint.as_str().into(),
RFC 3339 timestamps in identity and repos480 row.created_at.as_str().into(),
API and MCP server, Rust identity service, registration, site redesign481 ])?
482 .run()
483 .await?;
484 Ok(Outcome::Ok(row.into()))
485 }
486
487 /// Deletes a row the user owns from `table`.
488 async fn remove(&self, table: &str, a: RemoveArgs) -> Result<()> {
489 self.db
490 .prepare(format!("DELETE FROM {table} WHERE id = ? AND user_id = ?"))
491 .bind(&[a.id.into(), a.user.id.into()])?
492 .run()
493 .await?;
494 Ok(())
495 }
496}
497
498#[event(fetch)]
499async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
500 let Some(method) = rpc_method(&request) else {
501 return Response::error("Not found", 404);
502 };
503 let body: serde_json::Value = request.json().await?;
Email verification, password reset, and Git for AI scale positioning504 let identity = Identity {
505 db: env.d1("DB")?,
506 env,
507 };
API and MCP server, Rust identity service, registration, site redesign508
509 match method.as_str() {
510 "register" => reply(&identity.register(args(body)?).await?),
511 "sign_in" => reply(&identity.sign_in(args(body)?).await?),
Workspaces own repositories512 "create_workspace" => reply(&identity.create_workspace(args(body)?).await?),
513 "get_workspace" => reply(&identity.get_workspace(args(body)?).await?),
514 "list_members" => reply(&identity.list_members(args(body)?).await?),
515 "add_member" => reply(&identity.add_member(args(body)?).await?),
516 "remove_member" => reply(&identity.remove_member(args(body)?).await?),
Agents as a team: lifecycle, merge queue, billing and a new shell517 "update_workspace" => reply(&identity.update_workspace(args(body)?).await?),
518 "list_workspace_tokens" => reply(&identity.list_workspace_tokens(args(body)?).await?),
519 "create_workspace_token" => reply(&identity.create_workspace_token(args(body)?).await?),
520 "remove_workspace_token" => reply(&identity.remove_workspace_token(args(body)?).await?),
OAuth 2.1 sign-in for MCP clients and other applications521 "oauth_authorize" => reply(&identity.oauth_authorize(args(body)?).await?),
522 "oauth_exchange" => reply(&identity.oauth_exchange(args(body)?).await?),
523 "oauth_refresh" => reply(&identity.oauth_refresh(args(body)?).await?),
524 "list_oauth_grants" => reply(&identity.list_oauth_grants(args(body)?).await?),
525 "revoke_oauth_grant" => reply(&identity.revoke_oauth_grant(args(body)?).await?),
Device sign-in replaces registering and minting tokens over the API526 "device_start" => reply(&identity.device_start(args(body)?).await?),
527 "device_lookup" => reply(&identity.device_lookup(args(body)?).await?),
528 "device_resolve" => reply(&identity.device_resolve(args(body)?).await?),
529 "device_claim" => reply(&identity.device_claim(args(body)?).await?),
Email verification, password reset, and Git for AI scale positioning530 "resend_verification" => reply(&identity.resend_verification(args(body)?).await?),
531 "verify_email" => reply(&identity.verify_email(args(body)?).await?),
532 "request_password_reset" => reply(&identity.request_password_reset(args(body)?).await?),
533 "reset_password" => reply(&identity.reset_password(args(body)?).await?),
API and MCP server, Rust identity service, registration, site redesign534 "sign_out" => reply(&identity.sign_out(args(body)?).await?),
535 "user_for_session" => reply(&identity.user_for_session(args(body)?).await?),
536 "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?),
537 "user_for_access_token" => {
538 let a: TokenArgs = args(body)?;
539 reply(&identity.user_for_access_token(&a.token).await?)
540 }
541 "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
542 "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
543 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
544 "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
545 "remove_ssh_key" => reply(&identity.remove("ssh_keys", args(body)?).await?),
546 "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
547 "create_access_token" => reply(&identity.create_access_token(args(body)?).await?),
Agents as a team: lifecycle, merge queue, billing and a new shell548 "create_agent_token" => reply(&identity.create_agent_token(args(body)?).await?),
549 "agent_scope" => reply(&identity.agent_scope(args(body)?).await?),
API and MCP server, Rust identity service, registration, site redesign550 "remove_access_token" => reply(&identity.remove("access_tokens", args(body)?).await?),
551 _ => Response::error("Unknown method", 404),
552 }
553}