From 1e944ead317c4d218e74a6b8d16fa15accf8c5c6 Mon Sep 17 00:00:00 2001 From: oxalica Date: Tue, 10 Sep 2024 12:15:22 -0400 Subject: [PATCH] feat: impl basic peer chat --- blah-types/src/lib.rs | 39 +++++++-- blahctl/src/main.rs | 8 +- blahd/schema.sql | 16 +++- blahd/src/id.rs | 14 +++- blahd/src/lib.rs | 181 ++++++++++++++++++++++++++++++++++++------ blahd/tests/webapi.rs | 99 ++++++++++++++++++++--- docs/webapi.yaml | 68 ++++++++++++---- 7 files changed, 361 insertions(+), 64 deletions(-) diff --git a/blah-types/src/lib.rs b/blah-types/src/lib.rs index a8efb75..c753224 100644 --- a/blah-types/src/lib.rs +++ b/blah-types/src/lib.rs @@ -28,7 +28,9 @@ impl fmt::Display for Id { } impl Id { - pub const INVALID: Self = Id(i64::MAX); + pub const MIN: Self = Id(i64::MIN); + pub const MAX: Self = Id(i64::MAX); + pub const INVALID: Self = Self::MAX; } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -312,8 +314,8 @@ pub type ChatItem = WithSig; pub struct RoomMetadata { /// Room id. pub rid: Id, - /// Plain text room title. - pub title: String, + /// Plain text room title. None for peer chat. + pub title: Option, /// Room attributes. pub attrs: RoomAttrs, @@ -333,11 +335,23 @@ pub struct RoomMetadata { /// Only available with authentication. #[serde(skip_serializing_if = "Option::is_none")] pub member_permission: Option, + /// The peer user, if this is a peer chat room. + #[serde(skip_serializing_if = "Option::is_none")] + pub peer_user: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "typ", rename = "create_room")] -pub struct CreateRoomPayload { +#[serde(tag = "typ")] +pub enum CreateRoomPayload { + #[serde(rename = "create_room")] + Group(CreateGroup), + #[serde(rename = "create_peer_chat")] + PeerChat(CreatePeerChat), +} + +/// Multi-user room. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateGroup { pub attrs: RoomAttrs, /// The initial member list. Besides invariants of `RoomMemberList`, this also must include the /// room creator themselves, with the highest permission (-1). @@ -345,6 +359,12 @@ pub struct CreateRoomPayload { pub title: String, } +/// Peer-to-peer chat room with exactly two symmetric users. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreatePeerChat { + pub peer: UserKey, +} + /// A collection of room members, with these invariants: /// 1. Sorted by userkeys. /// 2. No duplicated users. @@ -408,10 +428,13 @@ pub enum RoomAdminOp { } bitflags::bitflags! { + /// TODO: Is this a really all about permission, or is a generic `UserFlags`? #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ServerPermission: u64 { const CREATE_ROOM = 1 << 0; + const ACCEPT_PEER_CHAT = 1 << 16; + const ALL = !0; } @@ -421,6 +444,7 @@ bitflags::bitflags! { const ADD_MEMBER = 1 << 1; const MAX_SELF_ADD = Self::POST_CHAT.bits(); + const MAX_PEER_CHAT = Self::POST_CHAT.bits(); const ALL = !0; } @@ -430,6 +454,11 @@ bitflags::bitflags! { const PUBLIC_READABLE = 1 << 0; const PUBLIC_JOINABLE = 1 << 1; + const GROUP_ATTRS = (1 << 16) - 1; + + // NB. Used by schema. + const PEER_CHAT = 1 << 16; + const _ = !0; } } diff --git a/blahctl/src/main.rs b/blahctl/src/main.rs index 912ad5a..593664b 100644 --- a/blahctl/src/main.rs +++ b/blahctl/src/main.rs @@ -4,8 +4,8 @@ use std::{fs, io}; use anyhow::{Context, Result}; use blah_types::{ - bitflags, get_timestamp, ChatPayload, CreateRoomPayload, Id, MemberPermission, RichText, - RoomAttrs, RoomMember, RoomMemberList, ServerPermission, UserKey, WithSig, + bitflags, get_timestamp, ChatPayload, CreateGroup, CreateRoomPayload, Id, MemberPermission, + RichText, RoomAttrs, RoomMember, RoomMemberList, ServerPermission, UserKey, WithSig, }; use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey}; @@ -217,7 +217,7 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> { attrs, } => { let key = load_signing_key(&private_key_file)?; - let payload = CreateRoomPayload { + let payload = CreateRoomPayload::Group(CreateGroup { attrs: attrs.unwrap_or_default(), title, // The CLI does not support passing multiple members because `User` itself is a @@ -226,7 +226,7 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> { permission: MemberPermission::ALL, user: UserKey(key.verifying_key().to_bytes()), }]), - }; + }); let payload = WithSig::sign(&key, get_timestamp(), &mut OsRng, payload)?; let ret = client diff --git a/blahd/schema.sql b/blahd/schema.sql index 9f6e4e2..0fadb97 100644 --- a/blahd/schema.sql +++ b/blahd/schema.sql @@ -7,12 +7,26 @@ CREATE TABLE IF NOT EXISTS `user` ( `permission` INTEGER NOT NULL DEFAULT 0 ) STRICT; +-- The highest bit of `rid` will be set for peer chat room. +-- So simply comparing it against 0 can filter them out. CREATE TABLE IF NOT EXISTS `room` ( `rid` INTEGER NOT NULL PRIMARY KEY, - `title` TEXT NOT NULL, + -- RoomAttrs::PEER_CHAT `attrs` INTEGER NOT NULL + CHECK ((`attrs` & 0x10000 == 0x10000) == `rid` < 0), + `title` TEXT + CHECK ((`title` ISNULL) == `rid` < 0), + + `peer1` INTEGER REFERENCES `user` ON DELETE RESTRICT + CHECK ((`peer1` NOTNULL) == `rid` < 0), + `peer2` INTEGER REFERENCES `user` ON DELETE RESTRICT + CHECK ((`peer2` NOTNULL AND `peer1` <= `peer2`) IS `rid` < 0) ) STRICT; +CREATE UNIQUE INDEX IF NOT EXISTS `ix_peer_chat` ON `room` + (`peer1`, `peer2`) + WHERE `rid` < 0; + CREATE TABLE IF NOT EXISTS `room_member` ( `rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE, `uid` INTEGER NOT NULL REFERENCES `user` ON DELETE RESTRICT, diff --git a/blahd/src/id.rs b/blahd/src/id.rs index 2604c70..c5ed5ca 100644 --- a/blahd/src/id.rs +++ b/blahd/src/id.rs @@ -7,6 +7,7 @@ use blah_types::Id; pub trait IdExt { fn gen() -> Self; + fn gen_peer_chat_rid() -> Self; } impl IdExt for Id { @@ -14,8 +15,15 @@ impl IdExt for Id { let timestamp = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .expect("after UNIX epoch"); - let timestamp_ms = timestamp.as_millis() as i64; - assert!(timestamp_ms > 0); - Id(timestamp_ms << 16) + let timestamp_ms = timestamp.as_millis(); + assert!( + 0 < timestamp_ms && timestamp_ms < (1 << 48), + "invalid timestamp", + ); + Id((timestamp_ms as i64) << 16) + } + + fn gen_peer_chat_rid() -> Self { + Id(Self::gen().0 | i64::MIN) } } diff --git a/blahd/src/lib.rs b/blahd/src/lib.rs index 6cb379a..0be1074 100644 --- a/blahd/src/lib.rs +++ b/blahd/src/lib.rs @@ -11,8 +11,9 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use axum_extra::extract::WithRejection as R; use blah_types::{ - ChatItem, ChatPayload, CreateRoomPayload, Id, MemberPermission, RoomAdminOp, RoomAdminPayload, - RoomAttrs, RoomMetadata, ServerPermission, Signee, UserKey, WithItemId, WithSig, + ChatItem, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission, + RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission, Signee, UserKey, + WithItemId, WithSig, }; use config::ServerConfig; use ed25519_dalek::SIGNATURE_LENGTH; @@ -177,7 +178,7 @@ async fn room_list( until_token: None, }; let page_len = pagination.effective_page_len(&st); - let start_rid = pagination.skip_token.unwrap_or(Id(0)); + let start_rid = pagination.skip_token.unwrap_or(Id::MIN); let query = |sql: &str, params: &[(&str, &dyn ToSql)]| -> Result { let rooms = st @@ -187,8 +188,6 @@ async fn room_list( .query_map(params, |row| { // TODO: Extract this into a function. let rid = row.get("rid")?; - let title = row.get("title")?; - let attrs = row.get("attrs")?; let last_item = row .get::<_, Option>("cid")? .map(|cid| { @@ -209,18 +208,16 @@ async fn room_list( }) }) .transpose()?; - let last_seen_cid = - Some(row.get::<_, Id>("last_seen_cid")?).filter(|cid| cid.0 != 0); - let unseen_cnt = row.get("unseen_cnt").ok(); - let member_permission = row.get("member_perm").ok(); Ok(RoomMetadata { rid, - title, - attrs, + title: row.get("title")?, + attrs: row.get("attrs")?, last_item, - last_seen_cid, - unseen_cnt, - member_permission, + last_seen_cid: Some(row.get::<_, Id>("last_seen_cid")?) + .filter(|cid| cid.0 != 0), + unseen_cnt: row.get("unseen_cnt").ok(), + member_permission: row.get("member_perm").ok(), + peer_user: row.get("peer_userkey").ok(), }) })? .collect::, _>>()?; @@ -255,12 +252,15 @@ async fn room_list( r" SELECT `rid`, `title`, `attrs`, `last_seen_cid`, `room_member`.`permission` AS `member_perm`, - `cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text` + `cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`, + `peer_user`.`userkey` AS `peer_userkey` FROM `user` JOIN `room_member` USING (`uid`) JOIN `room` USING (`rid`) LEFT JOIN `room_item` USING (`rid`) LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `room_item`.`uid`) + LEFT JOIN `user` AS `peer_user` ON + (`peer_user`.`uid` = `room`.`peer1` + `room`.`peer2` - `user`.`uid`) WHERE `user`.`userkey` = :userkey AND `rid` > :start_rid GROUP BY `rid` HAVING `cid` IS MAX(`cid`) @@ -281,6 +281,7 @@ async fn room_list( SELECT `rid`, `title`, `attrs`, `last_seen_cid`, `room_member`.`permission` AS `member_perm`, `cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`, + `peer_user`.`userkey` AS `peer_userkey`, (SELECT COUNT(*) FROM `room_item` AS `unseen_item` WHERE `unseen_item`.`rid` = `room`.`rid` AND @@ -290,6 +291,8 @@ async fn room_list( JOIN `room` USING (`rid`) LEFT JOIN `room_item` USING (`rid`) LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `room_item`.`uid`) + LEFT JOIN `user` AS `peer_user` ON + (`peer_user`.`uid` = `room`.`peer1` + `room`.`peer2` - `user`.`uid`) WHERE `user`.`userkey` = :userkey AND `rid` > :start_rid AND `cid` > `last_seen_cid` @@ -312,10 +315,29 @@ async fn room_create( st: ArcState, SignedJson(params): SignedJson, ) -> Result, ApiError> { - let members = ¶ms.signee.payload.members.0; + match params.signee.payload { + CreateRoomPayload::Group(op) => room_create_group(&st, params.signee.user, op).await, + CreateRoomPayload::PeerChat(op) => room_create_peer_chat(&st, params.signee.user, op).await, + } +} + +async fn room_create_group( + st: &AppState, + user: UserKey, + op: CreateGroup, +) -> Result, ApiError> { + if !RoomAttrs::GROUP_ATTRS.contains(op.attrs) { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "deserialization", + "invalid room attributes", + )); + } + + let members = &op.members.0; if !members .iter() - .any(|m| m.user == params.signee.user && m.permission == MemberPermission::ALL) + .any(|m| m.user == user && m.permission == MemberPermission::ALL) { return Err(error_response!( StatusCode::BAD_REQUEST, @@ -332,7 +354,7 @@ async fn room_create( FROM `user` WHERE `userkey` = ? ", - params![params.signee.user], + params![user], |row| { let perm = row.get::<_, ServerPermission>("permission")?; Ok(perm.contains(ServerPermission::CREATE_ROOM)) @@ -356,8 +378,8 @@ async fn room_create( ", named_params! { ":rid": rid, - ":title": params.signee.payload.title, - ":attrs": params.signee.payload.attrs, + ":title": op.title, + ":attrs": op.attrs, }, )?; let mut insert_user = txn.prepare( @@ -390,6 +412,118 @@ async fn room_create( Ok(Json(rid)) } +async fn room_create_peer_chat( + st: &AppState, + src_user: UserKey, + op: CreatePeerChat, +) -> Result, ApiError> { + let tgt_user = op.peer; + if tgt_user == src_user { + return Err(error_response!( + StatusCode::NOT_IMPLEMENTED, + "not_implemented", + "self-chat is not implemented yet", + )); + } + + // TODO: Access control and throttling. + + let mut conn = st.db.get(); + let txn = conn.transaction()?; + let tgt_uid = txn + .query_row( + r" + SELECT `uid` + FROM `user` + WHERE `userkey` = :userkey AND + `permission` & :perm = :perm + ", + named_params! { + ":userkey": tgt_user, + ":perm": ServerPermission::ACCEPT_PEER_CHAT, + }, + |row| row.get::<_, i64>(0), + ) + .optional()? + .ok_or_else(|| { + error_response!( + StatusCode::NOT_FOUND, + "not_found", + "peer user does not exist or disallows peer chat", + ) + })?; + let src_uid = txn + .query_row( + r" + SELECT `uid` FROM `user` + WHERE `userkey` = ? + ", + params![src_user], + |row| row.get::<_, i64>(0), + ) + .optional()?; + let src_uid = match src_uid { + Some(uid) => uid, + None => { + txn.execute( + r" + INSERT INTO `user` (`userkey`) + VALUES (?) + ", + params![src_user], + )?; + txn.last_insert_rowid() + } + }; + + let (peer1, peer2) = if src_uid <= tgt_uid { + (src_uid, tgt_uid) + } else { + (tgt_uid, src_uid) + }; + let rid = Id::gen_peer_chat_rid(); + let updated = txn.execute( + r" + INSERT INTO `room` (`rid`, `attrs`, `peer1`, `peer2`) + VALUES (:rid, :attrs, :peer1, :peer2) + ON CONFLICT (`peer1`, `peer2`) WHERE `rid` < 0 DO NOTHING + ", + named_params! { + ":rid": rid, + ":attrs": RoomAttrs::PEER_CHAT, + ":peer1": peer1, + ":peer2": peer2, + }, + )?; + if updated == 0 { + return Err(error_response!( + StatusCode::CONFLICT, + "exists", + "room already exists" + )); + } + + { + let mut stmt = txn.prepare( + r" + INSERT INTO `room_member` (`rid`, `uid`, `permission`) + VALUES (:rid, :uid, :perm) + ", + )?; + // TODO: Limit permission of the src user? + for uid in [peer1, peer2] { + stmt.execute(named_params! { + ":rid": rid, + ":uid": uid, + ":perm": MemberPermission::MAX_PEER_CHAT, + })?; + } + } + + txn.commit()?; + Ok(Json(rid)) +} + /// Pagination query parameters. /// /// Field names are inspired by Microsoft's design, which is an extension to OData spec. @@ -444,7 +578,7 @@ async fn room_get_metadata( let conn = st.db.get(); let (title, attrs) = get_room_if_readable(&conn, rid, auth.into_optional()?.as_ref(), |row| { Ok(( - row.get::<_, String>("title")?, + row.get::<_, Option>("title")?, row.get::<_, RoomAttrs>("attrs")?, )) })?; @@ -459,6 +593,7 @@ async fn room_get_metadata( last_seen_cid: None, unseen_cnt: None, member_permission: None, + peer_user: None, })) } @@ -624,8 +759,8 @@ fn query_room_items( .query_and_then( named_params! { ":rid": rid, - ":after_cid": pagination.until_token.unwrap_or(Id(-1)), - ":before_cid": pagination.skip_token.unwrap_or(Id(i64::MAX)), + ":after_cid": pagination.until_token.unwrap_or(Id::MIN), + ":before_cid": pagination.skip_token.unwrap_or(Id::MAX), ":limit": page_len, }, |row| { diff --git a/blahd/tests/webapi.rs b/blahd/tests/webapi.rs index 73f0310..4b6e29d 100644 --- a/blahd/tests/webapi.rs +++ b/blahd/tests/webapi.rs @@ -7,9 +7,9 @@ use std::sync::{Arc, LazyLock}; use anyhow::Result; use blah_types::{ - get_timestamp, AuthPayload, ChatItem, ChatPayload, CreateRoomPayload, Id, MemberPermission, - RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMember, RoomMemberList, RoomMetadata, - ServerPermission, UserKey, WithItemId, WithSig, + get_timestamp, AuthPayload, ChatItem, ChatPayload, CreateGroup, CreatePeerChat, + CreateRoomPayload, Id, MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, + RoomMember, RoomMemberList, RoomMetadata, ServerPermission, UserKey, WithItemId, WithSig, }; use blahd::{ApiError, AppState, Database, RoomItems, RoomList}; use ed25519_dalek::SigningKey; @@ -29,7 +29,7 @@ const LOCALHOST: &str = "127.0.0.1"; static ALICE_PRIV: LazyLock = LazyLock::new(|| SigningKey::from_bytes(&[b'A'; 32])); static ALICE: LazyLock = LazyLock::new(|| UserKey(ALICE_PRIV.verifying_key().to_bytes())); static BOB_PRIV: LazyLock = LazyLock::new(|| SigningKey::from_bytes(&[b'B'; 32])); -// static BOB: LazyLock = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes())); +static BOB: LazyLock = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes())); #[fixture] fn rng() -> impl RngCore { @@ -118,14 +118,14 @@ impl Server { let req = sign( key, &mut *self.rng.borrow_mut(), - CreateRoomPayload { + CreateRoomPayload::Group(CreateGroup { attrs, members: RoomMemberList(vec![RoomMember { permission: MemberPermission::ALL, user: UserKey(key.verifying_key().to_bytes()), }]), title: title.to_string(), - }, + }), ); async move { Ok(self @@ -254,9 +254,10 @@ fn auth(key: &SigningKey, rng: &mut impl RngCore) -> String { #[case::private(false)] #[tokio::test] async fn room_create_get(server: Server, ref mut rng: impl RngCore, #[case] public: bool) { + let title = "test room"; let mut room_meta = RoomMetadata { rid: Id(0), - title: "test room".into(), + title: Some(title.into()), attrs: if public { RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE } else { @@ -266,18 +267,19 @@ async fn room_create_get(server: Server, ref mut rng: impl RngCore, #[case] publ last_seen_cid: None, unseen_cnt: None, member_permission: None, + peer_user: None, }; // Alice has permission. let rid = server - .create_room(&ALICE_PRIV, room_meta.attrs, &room_meta.title) + .create_room(&ALICE_PRIV, room_meta.attrs, title) .await .unwrap(); room_meta.rid = rid; // Bob has no permission. server - .create_room(&BOB_PRIV, room_meta.attrs, &room_meta.title) + .create_room(&BOB_PRIV, room_meta.attrs, title) .await .expect_api_err(StatusCode::FORBIDDEN, "permission_denied"); @@ -579,12 +581,13 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) { RoomList { rooms: vec![RoomMetadata { rid, - title: title.into(), + title: Some(title.into()), attrs, last_item: Some(alice_chat2.clone()), last_seen_cid: None, unseen_cnt: Some(2), member_permission: Some(member_perm), + peer_user: None, }], skip_token: None, } @@ -612,12 +615,13 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) { RoomList { rooms: vec![RoomMetadata { rid, - title: title.into(), + title: Some(title.into()), attrs, last_item: Some(alice_chat2.clone()), last_seen_cid: Some(alice_chat1.cid), unseen_cnt: Some(1), member_permission: Some(member_perm), + peer_user: None, }], skip_token: None, } @@ -639,3 +643,76 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) { .unwrap(); assert_eq!(rooms, RoomList::default()); } + +#[rstest] +#[tokio::test] +async fn peer_chat(server: Server, ref mut rng: impl RngCore) { + let mut create_chat = |src: &SigningKey, tgt: &UserKey| { + let req = sign( + src, + rng, + CreateRoomPayload::PeerChat(CreatePeerChat { peer: tgt.clone() }), + ); + server + .request::<_, Id>(Method::POST, "/room/create", None, Some(req)) + .map_ok(|resp| resp.unwrap()) + }; + + // Bob disallows peer chat. + create_chat(&ALICE_PRIV, &BOB) + .await + .expect_api_err(StatusCode::NOT_FOUND, "not_found"); + + // Alice accepts bob. + let rid = create_chat(&BOB_PRIV, &ALICE).await.unwrap(); + + // Room already exists. + create_chat(&BOB_PRIV, &ALICE) + .await + .expect_api_err(StatusCode::CONFLICT, "exists"); + + // Peer chat room is not public. + let rooms = server + .get::("/room?filter=public", None) + .await + .unwrap(); + assert_eq!(rooms, RoomList::default()); + server + .get::(&format!("/room/{rid}"), None) + .await + .expect_api_err(StatusCode::NOT_FOUND, "not_found"); + + // Both alice and bob are in the room. + for (key, peer) in [(&*ALICE_PRIV, &*BOB), (&*BOB_PRIV, &*ALICE)] { + let mut expect_meta = RoomMetadata { + rid, + title: None, + attrs: RoomAttrs::PEER_CHAT, + last_item: None, + last_seen_cid: None, + unseen_cnt: None, + member_permission: None, + peer_user: None, + }; + + let meta = server + .get::(&format!("/room/{rid}"), Some(&auth(key, rng))) + .await + .unwrap(); + assert_eq!(meta, expect_meta); + + expect_meta.member_permission = Some(MemberPermission::MAX_PEER_CHAT); + expect_meta.peer_user = Some(peer.clone()); + let rooms = server + .get::("/room?filter=joined", Some(&auth(key, rng))) + .await + .unwrap(); + assert_eq!( + rooms, + RoomList { + rooms: vec![expect_meta], + skip_token: None + } + ); + } +} diff --git a/docs/webapi.yaml b/docs/webapi.yaml index 53f75c8..4064502 100644 --- a/docs/webapi.yaml +++ b/docs/webapi.yaml @@ -108,6 +108,13 @@ paths: /room/create: post: summary: Create room + + description: + When `typ="create_room"`, create a multi-user room. + + When `typ="create_peer_chat"`, create a peer-to-peer room between two + users. There can be at most one peer room for each given user pair. + requestBody: content: application/json: @@ -130,6 +137,20 @@ paths: schema: $ref: '#/components/schemas/ApiError' + 404: + description: The peer user does not exist or disallows peer chat. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + + 409: + description: There is already a peer chat room between the user pair. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + /room/{rid}: get: summary: Get room metadata @@ -391,6 +412,10 @@ components: member_permission: type: integer format: int64 + peer_user: + type: string + description: | + For peer chat room, this gives the identity of the peer user. RoomMetadata: type: object @@ -577,23 +602,32 @@ components: type: integer format: uint32 payload: - type: object - properties: - typ: - type: string - const: 'create_room' - title: - type: string - members: - type: array - items: - type: object - properties: - user: - type: string - permission: - type: integer - format: int64 + oneOf: + - type: object + properties: + typ: + type: string + const: 'create_room' + title: + type: string + members: + type: array + items: + type: object + properties: + user: + type: string + permission: + type: integer + format: int64 + - type: object + properties: + typ: + type: string + const: 'create_peer_chat' + peer: + type: string + example: sig: 99a77e836538268839ed3419c649eefb043cb51d448f641cc2a1c523811aab4aacd09f92e7c0688ffd659bfc6acb764fea79979a491e132bf6a56dd23adc1d09 signee: