Skip to main content

pylon_auth/
org.rs

1//! Organizations + memberships + invites — multi-tenant team management.
2//!
3//! Sits alongside the existing in-memory `OrganizationsPlugin` in
4//! `pylon_plugin::builtin::organizations` but with:
5//!   - Pluggable [`OrgBackend`] trait (in-memory default, SQLite + PG
6//!     backends in pylon-runtime so orgs survive a restart)
7//!   - Email invite flow with token + expiry + accept endpoint
8//!   - Role enforcement helpers
9//!
10//! The HTTP endpoints in `routes/auth.rs` use this directly. Apps
11//! that want their own org model can ignore the store and roll their
12//! own — pylon doesn't force the schema, only ships the backend +
13//! endpoints when you opt in.
14
15use std::collections::HashMap;
16use std::sync::Mutex;
17
18use serde::{Deserialize, Serialize};
19
20/// Role within an organization.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum OrgRole {
24    /// Can do everything, including deleting the org and reassigning
25    /// ownership. Multiple owners allowed (pass an existing owner's
26    /// successor before they leave).
27    Owner,
28    /// Manage members + invites + most settings, but cannot delete
29    /// the org or transfer ownership.
30    Admin,
31    /// Default role for invited members.
32    Member,
33}
34
35impl OrgRole {
36    pub fn from_str(s: &str) -> Option<Self> {
37        match s {
38            "owner" => Some(Self::Owner),
39            "admin" => Some(Self::Admin),
40            "member" => Some(Self::Member),
41            _ => None,
42        }
43    }
44    pub fn as_str(&self) -> &'static str {
45        match self {
46            Self::Owner => "owner",
47            Self::Admin => "admin",
48            Self::Member => "member",
49        }
50    }
51    pub fn can_manage_members(&self) -> bool {
52        matches!(self, Self::Owner | Self::Admin)
53    }
54    pub fn can_delete_org(&self) -> bool {
55        matches!(self, Self::Owner)
56    }
57    pub fn can_transfer_ownership(&self) -> bool {
58        matches!(self, Self::Owner)
59    }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Org {
64    pub id: String,
65    pub name: String,
66    /// User id of whoever created the org. Distinct from "owner" —
67    /// ownership can be transferred but creator is immutable.
68    pub created_by: String,
69    pub created_at: u64,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Membership {
74    pub org_id: String,
75    pub user_id: String,
76    pub role: OrgRole,
77    pub joined_at: u64,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Invite {
82    /// Stable id — `inv_<24-char-base64url>`. What you reference in
83    /// management UIs (revoke, resend).
84    pub id: String,
85    pub org_id: String,
86    /// Email of the invitee. Lowercased before storage so case-only
87    /// duplicates collapse.
88    pub email: String,
89    /// Role the invitee will receive on accept.
90    pub role: OrgRole,
91    /// User id of whoever sent the invite. Used in the email body
92    /// ("Alice invited you to Acme Corp").
93    pub invited_by: String,
94    /// Single-use random token — what the invitee clicks. Stored
95    /// hashed (Argon2) so a DB read doesn't leak active invites.
96    /// The plaintext is sent in the email and never persisted.
97    pub token_hash: String,
98    /// First 8 chars of the plaintext token — display in management
99    /// UIs so the inviter can identify which link they sent.
100    pub token_prefix: String,
101    pub created_at: u64,
102    pub expires_at: u64,
103    pub accepted_at: Option<u64>,
104}
105
106pub trait OrgBackend: Send + Sync {
107    fn put_org(&self, org: &Org);
108    fn get_org(&self, id: &str) -> Option<Org>;
109    fn delete_org(&self, id: &str) -> bool;
110    fn list_orgs_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)>;
111
112    fn put_membership(&self, m: &Membership);
113    fn get_membership(&self, org_id: &str, user_id: &str) -> Option<Membership>;
114    fn delete_membership(&self, org_id: &str, user_id: &str) -> bool;
115    fn list_members(&self, org_id: &str) -> Vec<Membership>;
116
117    fn put_invite(&self, inv: &Invite);
118    fn get_invite(&self, id: &str) -> Option<Invite>;
119    fn list_invites(&self, org_id: &str) -> Vec<Invite>;
120    fn delete_invite(&self, id: &str) -> bool;
121    /// All non-accepted invites whose plaintext starts with `prefix`.
122    /// SQL backends use a `WHERE token_prefix = $1 AND accepted_at IS NULL`
123    /// SELECT; the in-memory backend scans all invites. Argon2 verify
124    /// then runs against the candidate set in `accept_invite`.
125    fn invites_by_prefix(&self, prefix: &str) -> Vec<Invite>;
126    /// CAS — atomically stamp `accepted_at` ONLY when it's currently
127    /// NULL. Returns true if we won the race, false if another
128    /// concurrent verify got there first. Required so two parallel
129    /// accept calls with the same token can't BOTH create a
130    /// membership.
131    fn mark_invite_accepted(&self, id: &str, now: u64) -> bool;
132}
133
134pub struct InMemoryOrgBackend {
135    orgs: Mutex<HashMap<String, Org>>,
136    memberships: Mutex<HashMap<(String, String), Membership>>,
137    invites: Mutex<HashMap<String, Invite>>,
138}
139
140impl Default for InMemoryOrgBackend {
141    fn default() -> Self {
142        Self {
143            orgs: Mutex::new(HashMap::new()),
144            memberships: Mutex::new(HashMap::new()),
145            invites: Mutex::new(HashMap::new()),
146        }
147    }
148}
149
150impl OrgBackend for InMemoryOrgBackend {
151    fn put_org(&self, org: &Org) {
152        self.orgs
153            .lock()
154            .unwrap()
155            .insert(org.id.clone(), org.clone());
156    }
157    fn get_org(&self, id: &str) -> Option<Org> {
158        self.orgs.lock().unwrap().get(id).cloned()
159    }
160    fn delete_org(&self, id: &str) -> bool {
161        let removed = self.orgs.lock().unwrap().remove(id).is_some();
162        if removed {
163            self.memberships.lock().unwrap().retain(|(o, _), _| o != id);
164            self.invites
165                .lock()
166                .unwrap()
167                .retain(|_, inv| inv.org_id != id);
168        }
169        removed
170    }
171    fn list_orgs_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)> {
172        let m = self.memberships.lock().unwrap();
173        let o = self.orgs.lock().unwrap();
174        m.values()
175            .filter(|mem| mem.user_id == user_id)
176            .filter_map(|mem| o.get(&mem.org_id).map(|org| (org.clone(), mem.role)))
177            .collect()
178    }
179
180    fn put_membership(&self, m: &Membership) {
181        self.memberships
182            .lock()
183            .unwrap()
184            .insert((m.org_id.clone(), m.user_id.clone()), m.clone());
185    }
186    fn get_membership(&self, org_id: &str, user_id: &str) -> Option<Membership> {
187        self.memberships
188            .lock()
189            .unwrap()
190            .get(&(org_id.to_string(), user_id.to_string()))
191            .cloned()
192    }
193    fn delete_membership(&self, org_id: &str, user_id: &str) -> bool {
194        self.memberships
195            .lock()
196            .unwrap()
197            .remove(&(org_id.to_string(), user_id.to_string()))
198            .is_some()
199    }
200    fn list_members(&self, org_id: &str) -> Vec<Membership> {
201        self.memberships
202            .lock()
203            .unwrap()
204            .values()
205            .filter(|m| m.org_id == org_id)
206            .cloned()
207            .collect()
208    }
209
210    fn put_invite(&self, inv: &Invite) {
211        self.invites
212            .lock()
213            .unwrap()
214            .insert(inv.id.clone(), inv.clone());
215    }
216    fn get_invite(&self, id: &str) -> Option<Invite> {
217        self.invites.lock().unwrap().get(id).cloned()
218    }
219    fn list_invites(&self, org_id: &str) -> Vec<Invite> {
220        self.invites
221            .lock()
222            .unwrap()
223            .values()
224            .filter(|i| i.org_id == org_id && i.accepted_at.is_none())
225            .cloned()
226            .collect()
227    }
228    fn delete_invite(&self, id: &str) -> bool {
229        self.invites.lock().unwrap().remove(id).is_some()
230    }
231    fn invites_by_prefix(&self, prefix: &str) -> Vec<Invite> {
232        // Include accepted invites in the candidate set so the
233        // accept path can return `AlreadyAccepted` (good UX) instead
234        // of `NotFound` (confusing — looks like a typo in the link).
235        self.invites
236            .lock()
237            .unwrap()
238            .values()
239            .filter(|i| i.token_prefix == prefix)
240            .cloned()
241            .collect()
242    }
243    fn mark_invite_accepted(&self, id: &str, now: u64) -> bool {
244        let mut g = self.invites.lock().unwrap();
245        let Some(inv) = g.get_mut(id) else {
246            return false;
247        };
248        if inv.accepted_at.is_some() {
249            return false;
250        }
251        inv.accepted_at = Some(now);
252        true
253    }
254}
255
256pub struct OrgStore {
257    backend: Box<dyn OrgBackend>,
258}
259
260impl Default for OrgStore {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266#[derive(Debug, Clone)]
267pub struct InviteWithToken {
268    pub invite: Invite,
269    /// Plaintext token — show in `accept_url`, never persist. Lost
270    /// after this method returns.
271    pub token: String,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum AcceptError {
276    /// Token doesn't match any stored invite (typo, never sent,
277    /// or revoked by an admin). Frontend should ask the user to
278    /// request a fresh invite.
279    NotFound,
280    /// Invite is past `expires_at`. Frontend should ask for a resend.
281    Expired,
282    /// Invite was already redeemed by SOMEONE (possibly the same
283    /// user, possibly a different account that shared the email).
284    /// **Frontends should treat this as success** for UX — the user
285    /// is effectively in the org via that prior accept; surface as
286    /// "you're already a member" not as an error.
287    AlreadyAccepted,
288    /// The accepting user's email doesn't match the invite's
289    /// addressee. This is the security gate — surface as a real
290    /// error ("this invite was sent to <other-email>; sign in
291    /// with that account to accept").
292    EmailMismatch,
293    /// User is already a member of this org via a DIFFERENT path
294    /// (e.g. they created the org themselves, or accepted an earlier
295    /// invite). **Frontends should treat this as success** — the
296    /// invite was redundant.
297    AlreadyMember,
298}
299
300impl std::fmt::Display for AcceptError {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        f.write_str(match self {
303            Self::NotFound => "invite not found",
304            Self::Expired => "invite expired",
305            Self::AlreadyAccepted => "invite already accepted",
306            Self::EmailMismatch => "invite email doesn't match this account",
307            Self::AlreadyMember => "user is already a member of this org",
308        })
309    }
310}
311
312impl OrgStore {
313    pub fn new() -> Self {
314        Self::with_backend(Box::new(InMemoryOrgBackend::default()))
315    }
316
317    pub fn with_backend(backend: Box<dyn OrgBackend>) -> Self {
318        Self { backend }
319    }
320
321    /// Create an org. Creator becomes Owner.
322    pub fn create(&self, name: &str, creator_id: &str) -> Org {
323        let id = format!("org_{}", random_token(20));
324        let org = Org {
325            id: id.clone(),
326            name: name.to_string(),
327            created_by: creator_id.to_string(),
328            created_at: now_secs(),
329        };
330        self.backend.put_org(&org);
331        self.backend.put_membership(&Membership {
332            org_id: id,
333            user_id: creator_id.to_string(),
334            role: OrgRole::Owner,
335            joined_at: now_secs(),
336        });
337        org
338    }
339
340    pub fn get(&self, org_id: &str) -> Option<Org> {
341        self.backend.get_org(org_id)
342    }
343
344    pub fn list_for_user(&self, user_id: &str) -> Vec<(Org, OrgRole)> {
345        self.backend.list_orgs_for_user(user_id)
346    }
347
348    pub fn list_members(&self, org_id: &str) -> Vec<Membership> {
349        self.backend.list_members(org_id)
350    }
351
352    pub fn role_of(&self, org_id: &str, user_id: &str) -> Option<OrgRole> {
353        self.backend.get_membership(org_id, user_id).map(|m| m.role)
354    }
355
356    pub fn set_role(&self, org_id: &str, user_id: &str, role: OrgRole) -> bool {
357        if let Some(mut m) = self.backend.get_membership(org_id, user_id) {
358            m.role = role;
359            self.backend.put_membership(&m);
360            true
361        } else {
362            false
363        }
364    }
365
366    pub fn remove_member(&self, org_id: &str, user_id: &str) -> bool {
367        self.backend.delete_membership(org_id, user_id)
368    }
369
370    /// Delete an org + all its memberships + all pending invites.
371    pub fn delete(&self, org_id: &str) -> bool {
372        self.backend.delete_org(org_id)
373    }
374
375    /// Mint an invite. Returns the plaintext token alongside the
376    /// stored record — caller is responsible for emailing the
377    /// plaintext to the invitee. The token is single-use, expires
378    /// in 7 days, and is rejected for any account whose email
379    /// doesn't match the invite's `email` field.
380    pub fn create_invite(
381        &self,
382        org_id: &str,
383        email: &str,
384        role: OrgRole,
385        invited_by: &str,
386    ) -> InviteWithToken {
387        let id = format!("inv_{}", random_token(20));
388        let token = random_token(24);
389        let token_hash = crate::password::hash_password(&token);
390        let token_prefix: String = token.chars().take(8).collect();
391        let expires_at = now_secs() + 7 * 24 * 60 * 60; // 7 days
392        let invite = Invite {
393            id,
394            org_id: org_id.to_string(),
395            email: email.to_lowercase(),
396            role,
397            invited_by: invited_by.to_string(),
398            token_hash,
399            token_prefix,
400            created_at: now_secs(),
401            expires_at,
402            accepted_at: None,
403        };
404        self.backend.put_invite(&invite);
405        InviteWithToken { invite, token }
406    }
407
408    pub fn list_invites(&self, org_id: &str) -> Vec<Invite> {
409        self.backend.list_invites(org_id)
410    }
411
412    pub fn revoke_invite(&self, invite_id: &str) -> bool {
413        self.backend.delete_invite(invite_id)
414    }
415
416    /// Accept an invite. Verifies the token (Argon2 hash compare),
417    /// checks expiry + accepted-at, ensures the accepting user's
418    /// email matches the invite, and either creates the membership
419    /// or returns the right error variant. The invite row is
420    /// updated with `accepted_at` (not deleted) so the audit trail
421    /// stays intact.
422    pub fn accept_invite(
423        &self,
424        token: &str,
425        accepting_user_id: &str,
426        accepting_email: &str,
427    ) -> Result<Membership, AcceptError> {
428        // Linear scan for the matching token hash. At org-management
429        // scale (handfuls of pending invites per org) this is fine;
430        // an index by token-hash-prefix would help if it ever wasn't.
431        // We can't store the token directly because that would let a
432        // DB read hand attackers active invite links.
433        let invite = self
434            .find_invite_by_plaintext(token)
435            .ok_or(AcceptError::NotFound)?;
436        if invite.accepted_at.is_some() {
437            return Err(AcceptError::AlreadyAccepted);
438        }
439        if invite.expires_at <= now_secs() {
440            return Err(AcceptError::Expired);
441        }
442        if invite.email != accepting_email.to_lowercase() {
443            return Err(AcceptError::EmailMismatch);
444        }
445        if self
446            .backend
447            .get_membership(&invite.org_id, accepting_user_id)
448            .is_some()
449        {
450            return Err(AcceptError::AlreadyMember);
451        }
452        // Wave-4 codex P2: CAS the invite to accepted_at FIRST,
453        // BEFORE creating the membership. If two concurrent accepts
454        // arrive, only one wins the CAS and only one membership
455        // gets created. The loser sees AlreadyAccepted (the invite
456        // was just consumed by the winning request).
457        if !self.backend.mark_invite_accepted(&invite.id, now_secs()) {
458            return Err(AcceptError::AlreadyAccepted);
459        }
460        let membership = Membership {
461            org_id: invite.org_id.clone(),
462            user_id: accepting_user_id.to_string(),
463            role: invite.role,
464            joined_at: now_secs(),
465        };
466        self.backend.put_membership(&membership);
467        Ok(membership)
468    }
469
470    /// Resolve a plaintext invite token to its stored record.
471    /// Narrows by `token_prefix` (cheap SQL index lookup) then
472    /// Argon2-verifies the candidate set. Argon2 is non-deterministic
473    /// so we can't direct-lookup by hash — but invitations live for
474    /// 7 days max and prefix collisions are 64 bits → effectively 1
475    /// candidate per query in practice.
476    fn find_invite_by_plaintext(&self, token: &str) -> Option<Invite> {
477        let prefix: String = token.chars().take(8).collect();
478        for inv in self.backend.invites_by_prefix(&prefix) {
479            if crate::password::verify_password(token, &inv.token_hash) {
480                return Some(inv);
481            }
482        }
483        None
484    }
485}
486
487fn random_token(n_bytes: usize) -> String {
488    use rand::RngCore;
489    let mut bytes = vec![0u8; n_bytes];
490    rand::thread_rng().fill_bytes(&mut bytes);
491    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
492    URL_SAFE_NO_PAD.encode(bytes)
493}
494
495fn now_secs() -> u64 {
496    use std::time::{SystemTime, UNIX_EPOCH};
497    SystemTime::now()
498        .duration_since(UNIX_EPOCH)
499        .unwrap_or_default()
500        .as_secs()
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn create_org_makes_creator_owner() {
509        let store = OrgStore::new();
510        let org = store.create("Acme", "user-1");
511        assert!(org.id.starts_with("org_"));
512        assert_eq!(org.name, "Acme");
513        assert_eq!(store.role_of(&org.id, "user-1"), Some(OrgRole::Owner));
514    }
515
516    #[test]
517    fn list_for_user_returns_all_orgs() {
518        let store = OrgStore::new();
519        let a = store.create("A", "u1");
520        let _b = store.create("B", "u2");
521        let c = store.create("C", "u3");
522        store.set_role(&c.id, "u1", OrgRole::Member);
523        // u1 owns A and isn't in C yet — set_role only updates an
524        // existing membership, so add it via the backend.
525        store.backend.put_membership(&Membership {
526            org_id: c.id.clone(),
527            user_id: "u1".into(),
528            role: OrgRole::Member,
529            joined_at: 1,
530        });
531        let list = store.list_for_user("u1");
532        assert_eq!(list.len(), 2);
533        let names: Vec<_> = list.iter().map(|(o, _)| o.name.clone()).collect();
534        assert!(names.contains(&"A".to_string()));
535        assert!(names.contains(&"C".to_string()));
536        assert!(!names.contains(&"B".to_string()));
537    }
538
539    #[test]
540    fn role_helpers() {
541        assert!(OrgRole::Owner.can_manage_members());
542        assert!(OrgRole::Owner.can_delete_org());
543        assert!(OrgRole::Admin.can_manage_members());
544        assert!(!OrgRole::Admin.can_delete_org());
545        assert!(!OrgRole::Member.can_manage_members());
546    }
547
548    #[test]
549    fn delete_cascades_memberships_and_invites() {
550        let store = OrgStore::new();
551        let org = store.create("A", "owner-1");
552        let _inv = store.create_invite(&org.id, "x@example.com", OrgRole::Member, "owner-1");
553        assert_eq!(store.list_invites(&org.id).len(), 1);
554        assert_eq!(store.list_members(&org.id).len(), 1);
555        assert!(store.delete(&org.id));
556        assert!(store.get(&org.id).is_none());
557        assert!(store.list_members(&org.id).is_empty());
558        assert!(store.list_invites(&org.id).is_empty());
559    }
560
561    #[test]
562    fn accept_invite_creates_membership() {
563        let store = OrgStore::new();
564        let org = store.create("Acme", "owner-1");
565        let invited = store.create_invite(&org.id, "newbie@example.com", OrgRole::Admin, "owner-1");
566        let m = store
567            .accept_invite(&invited.token, "user-2", "newbie@example.com")
568            .expect("accept");
569        assert_eq!(m.role, OrgRole::Admin);
570        assert_eq!(store.role_of(&org.id, "user-2"), Some(OrgRole::Admin));
571        // Audit: invite stamped accepted, not deleted.
572        let stored = store.backend.get_invite(&invited.invite.id).unwrap();
573        assert!(stored.accepted_at.is_some());
574    }
575
576    #[test]
577    fn accept_invite_rejects_wrong_email() {
578        let store = OrgStore::new();
579        let org = store.create("Acme", "owner-1");
580        let invited = store.create_invite(&org.id, "alice@example.com", OrgRole::Member, "owner-1");
581        let err = store
582            .accept_invite(&invited.token, "user-2", "bob@example.com")
583            .unwrap_err();
584        assert_eq!(err, AcceptError::EmailMismatch);
585    }
586
587    #[test]
588    fn accept_invite_rejects_replay() {
589        let store = OrgStore::new();
590        let org = store.create("A", "owner");
591        let invited = store.create_invite(&org.id, "a@b.com", OrgRole::Member, "owner");
592        store
593            .accept_invite(&invited.token, "user-2", "a@b.com")
594            .unwrap();
595        let second = store.accept_invite(&invited.token, "user-2", "a@b.com");
596        assert_eq!(second.unwrap_err(), AcceptError::AlreadyAccepted);
597    }
598
599    /// Wave-4 codex P2 regression: concurrent accepts must not
600    /// both create a membership. The CAS via `mark_invite_accepted`
601    /// guarantees only one wins. Simulate by calling
602    /// `mark_invite_accepted` twice — the second call must return
603    /// false so the second accept_invite returns AlreadyAccepted.
604    #[test]
605    fn accept_invite_cas_blocks_concurrent_winners() {
606        let store = OrgStore::new();
607        let org = store.create("A", "owner");
608        let invited = store.create_invite(&org.id, "a@b.com", OrgRole::Member, "owner");
609
610        // Simulate: first request gets through to mark_invite_accepted
611        // and wins.
612        let won_first = store.backend.mark_invite_accepted(&invited.invite.id, 100);
613        assert!(won_first);
614        // Second concurrent request runs the same CAS and loses.
615        let won_second = store.backend.mark_invite_accepted(&invited.invite.id, 101);
616        assert!(!won_second);
617        // accept_invite called now would see consumed_at set and
618        // return AlreadyAccepted instead of double-creating.
619        let result = store.accept_invite(&invited.token, "user-x", "a@b.com");
620        assert_eq!(result.unwrap_err(), AcceptError::AlreadyAccepted);
621    }
622
623    #[test]
624    fn accept_invite_rejects_unknown_token() {
625        let store = OrgStore::new();
626        let _org = store.create("A", "owner");
627        let err = store
628            .accept_invite("not-a-real-token", "user-2", "x@y.com")
629            .unwrap_err();
630        assert_eq!(err, AcceptError::NotFound);
631    }
632
633    #[test]
634    fn invite_email_lowercased() {
635        let store = OrgStore::new();
636        let org = store.create("A", "owner");
637        let inv = store.create_invite(&org.id, "Mixed@CASE.com", OrgRole::Member, "owner");
638        assert_eq!(inv.invite.email, "mixed@case.com");
639    }
640
641    #[test]
642    fn revoke_invite() {
643        let store = OrgStore::new();
644        let org = store.create("A", "owner");
645        let inv = store.create_invite(&org.id, "x@y.com", OrgRole::Member, "owner");
646        assert!(store.revoke_invite(&inv.invite.id));
647        assert!(store.list_invites(&org.id).is_empty());
648    }
649
650    #[test]
651    fn remove_member() {
652        let store = OrgStore::new();
653        let org = store.create("A", "owner");
654        store.backend.put_membership(&Membership {
655            org_id: org.id.clone(),
656            user_id: "u2".into(),
657            role: OrgRole::Member,
658            joined_at: 1,
659        });
660        assert!(store.remove_member(&org.id, "u2"));
661        assert!(store.role_of(&org.id, "u2").is_none());
662    }
663}