summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json (renamed from .sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json)6
-rw-r--r--.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json50
-rw-r--r--.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json50
-rw-r--r--.sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json38
-rw-r--r--.sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json12
-rw-r--r--.sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json32
-rw-r--r--.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json (renamed from .sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json)14
-rw-r--r--.sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json38
-rw-r--r--.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json20
-rw-r--r--docs/api/boot.md9
-rw-r--r--src/app.rs11
-rw-r--r--src/boot/handlers/boot/mod.rs8
-rw-r--r--src/boot/handlers/boot/test.rs4
-rw-r--r--src/conversation/handlers/send/mod.rs7
-rw-r--r--src/conversation/handlers/send/test.rs2
-rw-r--r--src/event/handlers/stream/test/invite.rs4
-rw-r--r--src/event/handlers/stream/test/setup.rs2
-rw-r--r--src/event/handlers/stream/test/token.rs4
-rw-r--r--src/invite/app.rs48
-rw-r--r--src/invite/handlers/accept/test.rs4
-rw-r--r--src/invite/handlers/issue/mod.rs2
-rw-r--r--src/invite/handlers/issue/test.rs2
-rw-r--r--src/lib.rs1
-rw-r--r--src/login/app.rs114
-rw-r--r--src/login/handlers/login/mod.rs (renamed from src/user/handlers/login/mod.rs)13
-rw-r--r--src/login/handlers/login/test.rs (renamed from src/user/handlers/login/test.rs)16
-rw-r--r--src/login/handlers/logout/mod.rs (renamed from src/user/handlers/logout/mod.rs)8
-rw-r--r--src/login/handlers/logout/test.rs (renamed from src/user/handlers/logout/test.rs)3
-rw-r--r--src/login/handlers/mod.rs (renamed from src/user/handlers/mod.rs)2
-rw-r--r--src/login/handlers/password/mod.rs (renamed from src/user/handlers/password/mod.rs)10
-rw-r--r--src/login/handlers/password/test.rs (renamed from src/user/handlers/password/test.rs)2
-rw-r--r--src/login/id.rs27
-rw-r--r--src/login/mod.rs13
-rw-r--r--src/login/repo.rs145
-rw-r--r--src/message/app.rs121
-rw-r--r--src/message/handlers/delete/mod.rs11
-rw-r--r--src/message/handlers/delete/test.rs8
-rw-r--r--src/message/history.rs15
-rw-r--r--src/message/repo.rs10
-rw-r--r--src/routes.rs8
-rw-r--r--src/setup/app.rs4
-rw-r--r--src/test/fixtures/cookie.rs14
-rw-r--r--src/test/fixtures/identity.rs6
-rw-r--r--src/test/fixtures/invite.rs4
-rw-r--r--src/test/fixtures/login.rs21
-rw-r--r--src/test/fixtures/message.rs4
-rw-r--r--src/test/fixtures/mod.rs1
-rw-r--r--src/test/fixtures/user.rs25
-rw-r--r--src/test/verify/identity.rs6
-rw-r--r--src/test/verify/login.rs10
-rw-r--r--src/test/verify/token.rs8
-rw-r--r--src/token/app.rs118
-rw-r--r--src/token/extract/identity.rs4
-rw-r--r--src/token/mod.rs12
-rw-r--r--src/token/repo/auth.rs105
-rw-r--r--src/token/repo/mod.rs1
-rw-r--r--src/token/repo/token.rs43
-rw-r--r--src/user/app.rs14
-rw-r--r--src/user/create.rs28
-rw-r--r--src/user/history.rs26
-rw-r--r--src/user/id.rs17
-rw-r--r--src/user/mod.rs3
-rw-r--r--src/user/repo.rs89
-rw-r--r--ui/lib/session.svelte.js8
64 files changed, 820 insertions, 645 deletions
diff --git a/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json b/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json
index 99a05e3..ca93083 100644
--- a/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json
+++ b/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
- "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"id: Id\",\n login as \"login: user::Id\",\n issued_at as \"issued_at: DateTime\",\n last_used_at as \"last_used_at: DateTime\"\n ",
+ "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"id: Id\",\n login as \"login: login::Id\",\n issued_at as \"issued_at: DateTime\",\n last_used_at as \"last_used_at: DateTime\"\n ",
"describe": {
"columns": [
{
@@ -9,7 +9,7 @@
"type_info": "Text"
},
{
- "name": "login: user::Id",
+ "name": "login: login::Id",
"ordinal": 1,
"type_info": "Text"
},
@@ -34,5 +34,5 @@
false
]
},
- "hash": "2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72"
+ "hash": "047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271"
}
diff --git a/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json b/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json
deleted file mode 100644
index 4bc5119..0000000
--- a/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\",\n login.password as \"password: StoredHash\"\n from user\n join login using (id)\n where id = $1\n ",
- "describe": {
- "columns": [
- {
- "name": "id: user::Id",
- "ordinal": 0,
- "type_info": "Text"
- },
- {
- "name": "display_name: String",
- "ordinal": 1,
- "type_info": "Text"
- },
- {
- "name": "canonical_name: String",
- "ordinal": 2,
- "type_info": "Text"
- },
- {
- "name": "created_sequence: Sequence",
- "ordinal": 3,
- "type_info": "Integer"
- },
- {
- "name": "created_at: DateTime",
- "ordinal": 4,
- "type_info": "Text"
- },
- {
- "name": "password: StoredHash",
- "ordinal": 5,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 1
- },
- "nullable": [
- false,
- false,
- false,
- false,
- false,
- false
- ]
- },
- "hash": "0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c"
-}
diff --git a/.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json b/.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json
deleted file mode 100644
index f4ae749..0000000
--- a/.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\",\n login.password as \"password: StoredHash\"\n from user\n join login using (id)\n where login.canonical_name = $1\n ",
- "describe": {
- "columns": [
- {
- "name": "id: user::Id",
- "ordinal": 0,
- "type_info": "Text"
- },
- {
- "name": "display_name: String",
- "ordinal": 1,
- "type_info": "Text"
- },
- {
- "name": "canonical_name: String",
- "ordinal": 2,
- "type_info": "Text"
- },
- {
- "name": "created_sequence: Sequence",
- "ordinal": 3,
- "type_info": "Integer"
- },
- {
- "name": "created_at: DateTime",
- "ordinal": 4,
- "type_info": "Text"
- },
- {
- "name": "password: StoredHash",
- "ordinal": 5,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 1
- },
- "nullable": [
- false,
- false,
- false,
- false,
- false,
- false
- ]
- },
- "hash": "20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b"
-}
diff --git a/.sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json b/.sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json
new file mode 100644
index 0000000..b5c9c81
--- /dev/null
+++ b/.sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json
@@ -0,0 +1,38 @@
+{
+ "db_name": "SQLite",
+ "query": "\n select\n id as \"id: Id\",\n display_name,\n canonical_name,\n password as \"password: StoredHash\"\n from login\n where canonical_name = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "id: Id",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "display_name",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "canonical_name",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "password: StoredHash",
+ "ordinal": 3,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079"
+}
diff --git a/.sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json b/.sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json
new file mode 100644
index 0000000..05d04e3
--- /dev/null
+++ b/.sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n update login\n set password = $1\n where id = $2\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": []
+ },
+ "hash": "94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95"
+}
diff --git a/.sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json b/.sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json
new file mode 100644
index 0000000..56232dd
--- /dev/null
+++ b/.sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json
@@ -0,0 +1,32 @@
+{
+ "db_name": "SQLite",
+ "query": "\n select\n id as \"id: login::Id\",\n display_name,\n canonical_name\n from login\n where id = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "id: login::Id",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "display_name",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "canonical_name",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a"
+}
diff --git a/.sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json
index 1a4389f..789151d 100644
--- a/.sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json
+++ b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json
@@ -1,10 +1,10 @@
{
"db_name": "SQLite",
- "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\"\n from user\n join login using (id)\n where id = $1\n ",
+ "query": "\n select\n id as \"id: Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_at as \"created_at: DateTime\",\n user.created_sequence as \"created_sequence: Sequence\"\n from user\n join login using (id)\n where id = $1\n ",
"describe": {
"columns": [
{
- "name": "id: user::Id",
+ "name": "id: Id",
"ordinal": 0,
"type_info": "Text"
},
@@ -19,14 +19,14 @@
"type_info": "Text"
},
{
- "name": "created_sequence: Sequence",
+ "name": "created_at: DateTime",
"ordinal": 3,
- "type_info": "Integer"
+ "type_info": "Text"
},
{
- "name": "created_at: DateTime",
+ "name": "created_sequence: Sequence",
"ordinal": 4,
- "type_info": "Text"
+ "type_info": "Integer"
}
],
"parameters": {
@@ -40,5 +40,5 @@
false
]
},
- "hash": "48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509"
+ "hash": "c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e"
}
diff --git a/.sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json b/.sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json
new file mode 100644
index 0000000..00b0ab9
--- /dev/null
+++ b/.sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json
@@ -0,0 +1,38 @@
+{
+ "db_name": "SQLite",
+ "query": "\n select\n id as \"id: Id\",\n display_name,\n canonical_name,\n password as \"password: StoredHash\"\n from login\n where id = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "id: Id",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "display_name",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "canonical_name",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "password: StoredHash",
+ "ordinal": 3,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389"
+}
diff --git a/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json b/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json
deleted file mode 100644
index 200c357..0000000
--- a/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n update login\n set password = $1\n where id = $2\n returning id as \"id: Id\"\n ",
- "describe": {
- "columns": [
- {
- "name": "id: Id",
- "ordinal": 0,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 2
- },
- "nullable": [
- false
- ]
- },
- "hash": "cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c"
-}
diff --git a/docs/api/boot.md b/docs/api/boot.md
index d7e9144..cc59d79 100644
--- a/docs/api/boot.md
+++ b/docs/api/boot.md
@@ -37,7 +37,7 @@ This endpoint will respond with a status of
```json
{
- "user": {
+ "login": {
"name": "example username",
"id": "U1234abcd"
},
@@ -100,15 +100,14 @@ The response will include the following fields:
| Field | Type | Description |
| :------------- | :-------------- | :----------------------------------------------------------------------------------------------------------------------- |
-| `user` | object | The details of the caller's identity. |
+| `login` | object | The details of the caller's identity. |
| `resume_point` | integer | A resume point for [events](./events.md), such that the event stream will begin immediately after the included snapshot. |
| `heartbeat` | integer | The [heartbeat timeout](./events.md#heartbeat-events), in seconds, for events. |
| `events` | array of object | The events on the server up to the resume point. |
-Each element of the
-`events` object is an event, as described in [Events](./events.md). Events are provided in the same order as they would appear in the event stream response.
+Each element of the `events` object is an event, as described in [Events](./events.md). Events are provided in the same order as they would appear in the event stream response.
-The `user` object will include the following fields:
+The `login` object will include the following fields:
| Field | Type | Description |
| :----- | :----- | :--------------------------------------- |
diff --git a/src/app.rs b/src/app.rs
index ab8da7e..d0ffcc0 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -5,6 +5,7 @@ use crate::{
conversation::app::Conversations,
event::{self, app::Events},
invite::app::Invites,
+ login::app::Logins,
message::app::Messages,
setup::app::Setup,
token::{self, app::Tokens},
@@ -49,9 +50,8 @@ impl App {
Invites::new(&self.db, &self.events)
}
- #[cfg(test)]
- pub const fn users(&self) -> Users<'_> {
- Users::new(&self.db, &self.events)
+ pub const fn logins(&self) -> Logins<'_> {
+ Logins::new(&self.db, &self.token_events)
}
pub const fn messages(&self) -> Messages<'_> {
@@ -65,4 +65,9 @@ impl App {
pub const fn tokens(&self) -> Tokens<'_> {
Tokens::new(&self.db, &self.token_events)
}
+
+ #[cfg(test)]
+ pub const fn users(&self) -> Users<'_> {
+ Users::new(&self.db, &self.events)
+ }
}
diff --git a/src/boot/handlers/boot/mod.rs b/src/boot/handlers/boot/mod.rs
index 49691f7..3e022b1 100644
--- a/src/boot/handlers/boot/mod.rs
+++ b/src/boot/handlers/boot/mod.rs
@@ -7,8 +7,8 @@ use axum::{
use serde::Serialize;
use crate::{
- app::App, boot::Snapshot, error::Internal, event::Heartbeat, token::extract::Identity,
- user::User,
+ app::App, boot::Snapshot, error::Internal, event::Heartbeat, login::Login,
+ token::extract::Identity,
};
#[cfg(test)]
@@ -19,7 +19,7 @@ pub async fn handler(State(app): State<App>, identity: Identity) -> Result<Respo
let heartbeat = Heartbeat::TIMEOUT;
Ok(Response {
- user: identity.user,
+ login: identity.login,
heartbeat,
snapshot,
})
@@ -27,7 +27,7 @@ pub async fn handler(State(app): State<App>, identity: Identity) -> Result<Respo
#[derive(serde::Serialize)]
pub struct Response {
- pub user: User,
+ pub login: Login,
#[serde(serialize_with = "as_seconds")]
pub heartbeat: Duration,
#[serde(flatten)]
diff --git a/src/boot/handlers/boot/test.rs b/src/boot/handlers/boot/test.rs
index c7c511a..cb50442 100644
--- a/src/boot/handlers/boot/test.rs
+++ b/src/boot/handlers/boot/test.rs
@@ -12,7 +12,7 @@ async fn returns_identity() {
.await
.expect("boot always succeeds");
- assert_eq!(viewer.user, response.user);
+ assert_eq!(viewer.login, response.login);
}
#[tokio::test]
@@ -33,7 +33,7 @@ async fn includes_users() {
.filter_map(fixtures::event::user::created)
.exactly_one()
.expect("only one user has been created");
- assert_eq!(spectator, created.user)
+ assert_eq!(spectator.id, created.user.id);
}
#[tokio::test]
diff --git a/src/conversation/handlers/send/mod.rs b/src/conversation/handlers/send/mod.rs
index 1c8ac63..c8be59c 100644
--- a/src/conversation/handlers/send/mod.rs
+++ b/src/conversation/handlers/send/mod.rs
@@ -25,7 +25,7 @@ pub async fn handler(
) -> Result<Response, Error> {
let message = app
.messages()
- .send(&conversation, &identity.user, &sent_at, &request.body)
+ .send(&conversation, &identity.login, &sent_at, &request.body)
.await?;
Ok(Response(message))
@@ -57,7 +57,10 @@ impl IntoResponse for Error {
SendError::ConversationNotFound(_) | SendError::ConversationDeleted(_) => {
NotFound(error).into_response()
}
- SendError::Name(_) | SendError::Database(_) => Internal::from(error).into_response(),
+ SendError::SenderNotFound(_)
+ | SendError::SenderDeleted(_)
+ | SendError::Name(_)
+ | SendError::Database(_) => Internal::from(error).into_response(),
}
}
}
diff --git a/src/conversation/handlers/send/test.rs b/src/conversation/handlers/send/test.rs
index bd32510..8863090 100644
--- a/src/conversation/handlers/send/test.rs
+++ b/src/conversation/handlers/send/test.rs
@@ -55,7 +55,7 @@ async fn messages_in_order() {
.await
{
assert_eq!(*sent_at, event.at());
- assert_eq!(sender.user.id, event.message.sender);
+ assert_eq!(sender.login.id, event.message.sender);
assert_eq!(body, event.message.body);
}
}
diff --git a/src/event/handlers/stream/test/invite.rs b/src/event/handlers/stream/test/invite.rs
index ce22384..d4ac82d 100644
--- a/src/event/handlers/stream/test/invite.rs
+++ b/src/event/handlers/stream/test/invite.rs
@@ -44,7 +44,7 @@ async fn accepting_invite() {
let _ = events
.filter_map(fixtures::event::stream::user)
.filter_map(fixtures::event::stream::user::created)
- .filter(|event| future::ready(event.user == joiner.user))
+ .filter(|event| future::ready(event.user.id == joiner.login.id))
.next()
.expect_some("a login created event is sent")
.await;
@@ -90,7 +90,7 @@ async fn previously_accepted_invite() {
let _ = events
.filter_map(fixtures::event::stream::user)
.filter_map(fixtures::event::stream::user::created)
- .filter(|event| future::ready(event.user == joiner.user))
+ .filter(|event| future::ready(event.user.id == joiner.login.id))
.next()
.expect_some("a login created event is sent")
.await;
diff --git a/src/event/handlers/stream/test/setup.rs b/src/event/handlers/stream/test/setup.rs
index 1fd2b7c..3f01ccb 100644
--- a/src/event/handlers/stream/test/setup.rs
+++ b/src/event/handlers/stream/test/setup.rs
@@ -45,7 +45,7 @@ async fn previously_completed() {
let _ = events
.filter_map(fixtures::event::stream::user)
.filter_map(fixtures::event::stream::user::created)
- .filter(|event| future::ready(event.user == owner.user))
+ .filter(|event| future::ready(event.user.id == owner.login.id))
.next()
.expect_some("a login created event is sent")
.await;
diff --git a/src/event/handlers/stream/test/token.rs b/src/event/handlers/stream/test/token.rs
index 5af07a0..a9dfe29 100644
--- a/src/event/handlers/stream/test/token.rs
+++ b/src/event/handlers/stream/test/token.rs
@@ -125,8 +125,8 @@ async fn terminates_on_password_change() {
let (_, password) = creds;
let to = fixtures::user::propose_password();
- app.tokens()
- .change_password(&subscriber.user, &password, &to, &fixtures::now())
+ app.logins()
+ .change_password(&subscriber.login, &password, &to, &fixtures::now())
.await
.expect("expiring tokens succeeds");
diff --git a/src/invite/app.rs b/src/invite/app.rs
index 6e235b2..6684d03 100644
--- a/src/invite/app.rs
+++ b/src/invite/app.rs
@@ -5,13 +5,15 @@ use super::{Id, Invite, Summary, repo::Provider as _};
use crate::{
clock::DateTime,
db::{Duplicate as _, NotFound as _},
- event::Broadcaster,
- name::Name,
+ event::{Broadcaster, repo::Provider as _},
+ login::Login,
+ name::{self, Name},
password::Password,
token::{Secret, Token, repo::Provider as _},
user::{
- User,
+ self,
create::{self, Create},
+ repo::{LoadError, Provider as _},
},
};
@@ -25,9 +27,19 @@ impl<'a> Invites<'a> {
Self { db, events }
}
- pub async fn issue(&self, issuer: &User, issued_at: &DateTime) -> Result<Invite, sqlx::Error> {
+ pub async fn issue(&self, issuer: &Login, issued_at: &DateTime) -> Result<Invite, Error> {
+ let issuer_not_found = || Error::IssuerNotFound(issuer.id.clone().into());
+ let issuer_deleted = || Error::IssuerDeleted(issuer.id.clone().into());
+
let mut tx = self.db.begin().await?;
- let invite = tx.invites().create(issuer, issued_at).await?;
+ let issuer = tx
+ .users()
+ .by_login(issuer)
+ .await
+ .not_found(issuer_not_found)?;
+ let now = tx.sequence().current().await?;
+ let issuer = issuer.as_of(now).ok_or_else(issuer_deleted)?;
+ let invite = tx.invites().create(&issuer, issued_at).await?;
tx.commit().await?;
Ok(invite)
@@ -71,8 +83,8 @@ impl<'a> Invites<'a> {
.store(&mut tx)
.await
.duplicate(|| AcceptError::DuplicateLogin(name.clone()))?;
- let user = stored.user().as_created();
- let (token, secret) = Token::generate(&user, accepted_at);
+ let login = stored.login();
+ let (token, secret) = Token::generate(login, accepted_at);
tx.tokens().create(&token, &secret).await?;
tx.commit().await?;
@@ -94,6 +106,28 @@ impl<'a> Invites<'a> {
}
#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error("issuing user {0} not found")]
+ IssuerNotFound(user::Id),
+ #[error("issuing user {0} deleted")]
+ IssuerDeleted(user::Id),
+ #[error(transparent)]
+ Database(#[from] sqlx::Error),
+ #[error(transparent)]
+ Name(#[from] name::Error),
+}
+
+impl From<user::repo::LoadError> for Error {
+ fn from(error: LoadError) -> Self {
+ use user::repo::LoadError;
+ match error {
+ LoadError::Database(error) => error.into(),
+ LoadError::Name(error) => error.into(),
+ }
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
pub enum AcceptError {
#[error("invite not found: {0}")]
NotFound(Id),
diff --git a/src/invite/handlers/accept/test.rs b/src/invite/handlers/accept/test.rs
index 4e4a09d..283ec76 100644
--- a/src/invite/handlers/accept/test.rs
+++ b/src/invite/handlers/accept/test.rs
@@ -44,8 +44,8 @@ async fn valid_invite() {
// Verify that the given credentials can log in
let secret = app
- .tokens()
- .login(&name, &password, &fixtures::now())
+ .logins()
+ .with_password(&name, &password, &fixtures::now())
.await
.expect("credentials given on signup are valid");
verify::token::valid_for_name(&app, &secret, &name).await;
diff --git a/src/invite/handlers/issue/mod.rs b/src/invite/handlers/issue/mod.rs
index 6085f7a..4ac74cc 100644
--- a/src/invite/handlers/issue/mod.rs
+++ b/src/invite/handlers/issue/mod.rs
@@ -13,7 +13,7 @@ pub async fn handler(
identity: Identity,
_: Json<Request>,
) -> Result<Json<Invite>, Internal> {
- let invite = app.invites().issue(&identity.user, &issued_at).await?;
+ let invite = app.invites().issue(&identity.login, &issued_at).await?;
Ok(Json(invite))
}
diff --git a/src/invite/handlers/issue/test.rs b/src/invite/handlers/issue/test.rs
index 2bf5400..4421705 100644
--- a/src/invite/handlers/issue/test.rs
+++ b/src/invite/handlers/issue/test.rs
@@ -22,6 +22,6 @@ async fn create_invite() {
.expect("creating an invite always succeeds");
// Verify the response
- assert_eq!(issuer.user.id, invite.issuer);
+ assert_eq!(issuer.login.id, invite.issuer);
assert_eq!(&*issued_at, &invite.issued_at);
}
diff --git a/src/lib.rs b/src/lib.rs
index b3299d7..f05cce3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -15,6 +15,7 @@ mod event;
mod expire;
mod id;
mod invite;
+mod login;
mod message;
mod name;
mod normalize;
diff --git a/src/login/app.rs b/src/login/app.rs
new file mode 100644
index 0000000..77d4ac3
--- /dev/null
+++ b/src/login/app.rs
@@ -0,0 +1,114 @@
+use sqlx::sqlite::SqlitePool;
+
+use crate::{
+ clock::DateTime,
+ db::NotFound as _,
+ login::{self, Login, repo::Provider as _},
+ name::{self, Name},
+ password::Password,
+ token::{Broadcaster, Event as TokenEvent, Secret, Token, repo::Provider as _},
+};
+
+pub struct Logins<'a> {
+ db: &'a SqlitePool,
+ token_events: &'a Broadcaster,
+}
+
+impl<'a> Logins<'a> {
+ pub const fn new(db: &'a SqlitePool, token_events: &'a Broadcaster) -> Self {
+ Self { db, token_events }
+ }
+
+ pub async fn with_password(
+ &self,
+ name: &Name,
+ candidate: &Password,
+ login_at: &DateTime,
+ ) -> Result<Secret, LoginError> {
+ let mut tx = self.db.begin().await?;
+ let (login, password) = tx
+ .logins()
+ .by_name(name)
+ .await
+ .not_found(|| LoginError::Rejected)?;
+ // Split the transaction here to avoid holding the tx open (potentially blocking
+ // other writes) while we do the fairly expensive task of verifying the
+ // password. It's okay if the token issuance transaction happens some notional
+ // amount of time after retrieving the login, as inserting the token will fail
+ // if the account is deleted during that time.
+ tx.commit().await?;
+
+ if password.verify(candidate)? {
+ let mut tx = self.db.begin().await?;
+ let (token, secret) = Token::generate(&login, login_at);
+ tx.tokens().create(&token, &secret).await?;
+ tx.commit().await?;
+ Ok(secret)
+ } else {
+ Err(LoginError::Rejected)
+ }
+ }
+
+ pub async fn change_password(
+ &self,
+ login: &Login,
+ from: &Password,
+ to: &Password,
+ changed_at: &DateTime,
+ ) -> Result<Secret, LoginError> {
+ let mut tx = self.db.begin().await?;
+ let (login, password) = tx
+ .logins()
+ .by_id(&login.id)
+ .await
+ .not_found(|| LoginError::Rejected)?;
+ // Split the transaction here to avoid holding the tx open (potentially blocking
+ // other writes) while we do the fairly expensive task of verifying the
+ // password. It's okay if the token issuance transaction happens some notional
+ // amount of time after retrieving the login, as inserting the token will fail
+ // if the account is deleted during that time.
+ tx.commit().await?;
+
+ if password.verify(from)? {
+ let to_hash = to.hash()?;
+ let (token, secret) = Token::generate(&login, changed_at);
+
+ let mut tx = self.db.begin().await?;
+ tx.logins().set_password(&login, &to_hash).await?;
+
+ let revoked = tx.tokens().revoke_all(&login).await?;
+ tx.tokens().create(&token, &secret).await?;
+ tx.commit().await?;
+
+ for event in revoked.into_iter().map(TokenEvent::Revoked) {
+ self.token_events.broadcast(event);
+ }
+
+ Ok(secret)
+ } else {
+ Err(LoginError::Rejected)
+ }
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum LoginError {
+ #[error("invalid login")]
+ Rejected,
+ #[error(transparent)]
+ Database(#[from] sqlx::Error),
+ #[error(transparent)]
+ Name(#[from] name::Error),
+ #[error(transparent)]
+ PasswordHash(#[from] password_hash::Error),
+}
+
+impl From<login::repo::LoadError> for LoginError {
+ fn from(error: login::repo::LoadError) -> Self {
+ use login::repo::LoadError;
+ match error {
+ LoadError::Database(error) => error.into(),
+ LoadError::Name(error) => error.into(),
+ }
+ }
+}
diff --git a/src/user/handlers/login/mod.rs b/src/login/handlers/login/mod.rs
index d3e0e8c..6591984 100644
--- a/src/user/handlers/login/mod.rs
+++ b/src/login/handlers/login/mod.rs
@@ -5,13 +5,8 @@ use axum::{
};
use crate::{
- app::App,
- clock::RequestedAt,
- empty::Empty,
- error::Internal,
- name::Name,
- password::Password,
- token::{app, extract::IdentityCookie},
+ app::App, clock::RequestedAt, empty::Empty, error::Internal, login::app, name::Name,
+ password::Password, token::extract::IdentityCookie,
};
#[cfg(test)]
@@ -24,8 +19,8 @@ pub async fn handler(
Json(request): Json<Request>,
) -> Result<(IdentityCookie, Empty), Error> {
let secret = app
- .tokens()
- .login(&request.name, &request.password, &now)
+ .logins()
+ .with_password(&request.name, &request.password, &now)
.await
.map_err(Error)?;
let identity = identity.set(secret);
diff --git a/src/user/handlers/login/test.rs b/src/login/handlers/login/test.rs
index 56fc2c4..f3911d0 100644
--- a/src/user/handlers/login/test.rs
+++ b/src/login/handlers/login/test.rs
@@ -2,8 +2,8 @@ use axum::extract::{Json, State};
use crate::{
empty::Empty,
+ login::app::LoginError,
test::{fixtures, verify},
- token::app,
};
#[tokio::test]
@@ -28,7 +28,12 @@ async fn correct_credentials() {
// Verify the return value's basic structure
- verify::identity::valid_for_name(&app, &identity, &name).await;
+ let secret = identity
+ .secret()
+ .expect("logged in with valid credentials issues an identity cookie");
+
+ // Verify the semantics
+ verify::token::valid_for_name(&app, &secret, &name).await;
}
#[tokio::test]
@@ -53,7 +58,7 @@ async fn invalid_name() {
// Verify the return value's basic structure
- assert!(matches!(error, app::LoginError::Rejected));
+ assert!(matches!(error, LoginError::Rejected));
}
#[tokio::test]
@@ -78,7 +83,7 @@ async fn incorrect_password() {
// Verify the return value's basic structure
- assert!(matches!(error, app::LoginError::Rejected));
+ assert!(matches!(error, LoginError::Rejected));
}
#[tokio::test]
@@ -100,9 +105,8 @@ async fn token_expires() {
// Verify the semantics
- let expired_at = fixtures::now();
app.tokens()
- .expire(&expired_at)
+ .expire(&fixtures::now())
.await
.expect("expiring tokens never fails");
diff --git a/src/user/handlers/logout/mod.rs b/src/login/handlers/logout/mod.rs
index f759451..73efe73 100644
--- a/src/user/handlers/logout/mod.rs
+++ b/src/login/handlers/logout/mod.rs
@@ -21,8 +21,8 @@ pub async fn handler(
Json(_): Json<Request>,
) -> Result<(IdentityCookie, Empty), Error> {
if let Some(secret) = identity.secret() {
- let validated_ident = app.tokens().validate(&secret, &now).await?;
- app.tokens().logout(&validated_ident.token).await?;
+ let identity = app.tokens().validate(&secret, &now).await?;
+ app.tokens().logout(&identity.token).await?;
}
let identity = identity.clear();
@@ -42,9 +42,7 @@ impl IntoResponse for Error {
fn into_response(self) -> Response {
let Self(error) = self;
match error {
- app::ValidateError::InvalidToken | app::ValidateError::LoginDeleted => {
- Unauthorized.into_response()
- }
+ app::ValidateError::InvalidToken => Unauthorized.into_response(),
app::ValidateError::Name(_) | app::ValidateError::Database(_) => {
Internal::from(error).into_response()
}
diff --git a/src/user/handlers/logout/test.rs b/src/login/handlers/logout/test.rs
index 8ad4853..e7b7dd4 100644
--- a/src/user/handlers/logout/test.rs
+++ b/src/login/handlers/logout/test.rs
@@ -14,7 +14,6 @@ async fn successful() {
let now = fixtures::now();
let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await;
let identity = fixtures::cookie::logged_in(&app, &creds, &now).await;
- let secret = fixtures::cookie::secret(&identity);
// Call the endpoint
@@ -31,7 +30,7 @@ async fn successful() {
assert!(response_identity.secret().is_none());
// Verify the semantics
- verify::token::invalid(&app, &secret).await;
+ verify::identity::invalid(&app, &identity).await;
}
#[tokio::test]
diff --git a/src/user/handlers/mod.rs b/src/login/handlers/mod.rs
index 5cadbb5..24ee7f9 100644
--- a/src/user/handlers/mod.rs
+++ b/src/login/handlers/mod.rs
@@ -1,6 +1,6 @@
mod login;
mod logout;
-mod password;
+pub mod password;
pub use login::handler as login;
pub use logout::handler as logout;
diff --git a/src/user/handlers/password/mod.rs b/src/login/handlers/password/mod.rs
index 5e69c1c..94c7fb4 100644
--- a/src/user/handlers/password/mod.rs
+++ b/src/login/handlers/password/mod.rs
@@ -9,11 +9,9 @@ use crate::{
clock::RequestedAt,
empty::Empty,
error::Internal,
+ login::app,
password::Password,
- token::{
- app,
- extract::{Identity, IdentityCookie},
- },
+ token::extract::{Identity, IdentityCookie},
};
#[cfg(test)]
@@ -27,8 +25,8 @@ pub async fn handler(
Json(request): Json<Request>,
) -> Result<(IdentityCookie, Empty), Error> {
let secret = app
- .tokens()
- .change_password(&identity.user, &request.password, &request.to, &now)
+ .logins()
+ .change_password(&identity.login, &request.password, &request.to, &now)
.await
.map_err(Error)?;
let cookie = cookie.set(secret);
diff --git a/src/user/handlers/password/test.rs b/src/login/handlers/password/test.rs
index 81020a1..ba2f28f 100644
--- a/src/user/handlers/password/test.rs
+++ b/src/login/handlers/password/test.rs
@@ -34,7 +34,7 @@ async fn password_change() {
assert_ne!(cookie.secret(), new_cookie.secret());
// Verify that we're still ourselves
- verify::identity::valid_for_user(&app, &new_cookie, &identity.user).await;
+ verify::identity::valid_for_login(&app, &new_cookie, &identity.login).await;
// Verify that our original token is no longer valid
verify::identity::invalid(&app, &cookie).await;
diff --git a/src/login/id.rs b/src/login/id.rs
new file mode 100644
index 0000000..ab16a15
--- /dev/null
+++ b/src/login/id.rs
@@ -0,0 +1,27 @@
+use crate::user;
+
+pub type Id = crate::id::Id<Login>;
+
+// Login IDs and user IDs are typewise-distinct as they identify things in different namespaces, but
+// in practice a login and its associated user _must_ have IDs that encode to the same value. The
+// two ID types are made interconvertible (via `From`) for this purpose.
+impl From<user::Id> for Id {
+ fn from(user: user::Id) -> Self {
+ Self::from(String::from(user))
+ }
+}
+
+impl PartialEq<user::Id> for Id {
+ fn eq(&self, other: &user::Id) -> bool {
+ self.as_str().eq(other.as_str())
+ }
+}
+
+#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct Login;
+
+impl crate::id::Prefix for Login {
+ fn prefix(&self) -> &'static str {
+ user::id::User.prefix()
+ }
+}
diff --git a/src/login/mod.rs b/src/login/mod.rs
new file mode 100644
index 0000000..bccc2af
--- /dev/null
+++ b/src/login/mod.rs
@@ -0,0 +1,13 @@
+pub mod app;
+pub mod handlers;
+mod id;
+pub mod repo;
+
+use crate::name::Name;
+pub use id::Id;
+
+#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
+pub struct Login {
+ pub id: Id,
+ pub name: Name,
+}
diff --git a/src/login/repo.rs b/src/login/repo.rs
new file mode 100644
index 0000000..5be91ad
--- /dev/null
+++ b/src/login/repo.rs
@@ -0,0 +1,145 @@
+use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite};
+
+use super::{Id, Login};
+use crate::{
+ db::NotFound,
+ name::{self, Name},
+ password::StoredHash,
+};
+
+pub trait Provider {
+ fn logins(&mut self) -> Logins<'_>;
+}
+
+impl Provider for Transaction<'_, Sqlite> {
+ fn logins(&mut self) -> Logins<'_> {
+ Logins(self)
+ }
+}
+
+pub struct Logins<'t>(&'t mut SqliteConnection);
+
+impl Logins<'_> {
+ pub async fn create(
+ &mut self,
+ login: &Login,
+ password: &StoredHash,
+ ) -> Result<(), sqlx::Error> {
+ let Login { id, name } = login;
+ let display_name = name.display();
+ let canonical_name = name.canonical();
+
+ sqlx::query!(
+ r#"
+ insert into login (id, display_name, canonical_name, password)
+ values ($1, $2, $3, $4)
+ "#,
+ id,
+ display_name,
+ canonical_name,
+ password,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn by_id(&mut self, id: &Id) -> Result<(Login, StoredHash), LoadError> {
+ let user = sqlx::query!(
+ r#"
+ select
+ id as "id: Id",
+ display_name,
+ canonical_name,
+ password as "password: StoredHash"
+ from login
+ where id = $1
+ "#,
+ id,
+ )
+ .map(|row| {
+ Ok::<_, LoadError>((
+ Login {
+ id: row.id,
+ name: Name::new(row.display_name, row.canonical_name)?,
+ },
+ row.password,
+ ))
+ })
+ .fetch_one(&mut *self.0)
+ .await??;
+
+ Ok(user)
+ }
+
+ pub async fn by_name(&mut self, name: &Name) -> Result<(Login, StoredHash), LoadError> {
+ let canonical_name = name.canonical();
+
+ let (login, password) = sqlx::query!(
+ r#"
+ select
+ id as "id: Id",
+ display_name,
+ canonical_name,
+ password as "password: StoredHash"
+ from login
+ where canonical_name = $1
+ "#,
+ canonical_name,
+ )
+ .map(|row| {
+ Ok::<_, LoadError>((
+ Login {
+ id: row.id,
+ name: Name::new(row.display_name, row.canonical_name)?,
+ },
+ row.password,
+ ))
+ })
+ .fetch_one(&mut *self.0)
+ .await??;
+
+ Ok((login, password))
+ }
+
+ pub async fn set_password(
+ &mut self,
+ login: &Login,
+ password: &StoredHash,
+ ) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ update login
+ set password = $1
+ where id = $2
+ "#,
+ password,
+ login.id,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error(transparent)]
+pub enum LoadError {
+ Database(#[from] sqlx::Error),
+ Name(#[from] name::Error),
+}
+
+impl<T> NotFound for Result<T, LoadError> {
+ type Ok = T;
+ type Error = LoadError;
+
+ fn optional(self) -> Result<Option<T>, LoadError> {
+ match self {
+ Ok(value) => Ok(Some(value)),
+ Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None),
+ Err(other) => Err(other),
+ }
+ }
+}
diff --git a/src/message/app.rs b/src/message/app.rs
index bdc2164..9100224 100644
--- a/src/message/app.rs
+++ b/src/message/app.rs
@@ -8,8 +8,9 @@ use crate::{
conversation::{self, repo::Provider as _},
db::NotFound as _,
event::{Broadcaster, Event, Sequence, repo::Provider as _},
+ login::Login,
name,
- user::User,
+ user::{self, repo::Provider as _},
};
pub struct Messages<'a> {
@@ -25,27 +26,35 @@ impl<'a> Messages<'a> {
pub async fn send(
&self,
conversation: &conversation::Id,
- sender: &User,
+ sender: &Login,
sent_at: &DateTime,
body: &Body,
) -> Result<Message, SendError> {
- let to_not_found = || SendError::ConversationNotFound(conversation.clone());
- let to_deleted = || SendError::ConversationDeleted(conversation.clone());
+ let conversation_not_found = || SendError::ConversationNotFound(conversation.clone());
+ let conversation_deleted = || SendError::ConversationDeleted(conversation.clone());
+ let sender_not_found = || SendError::SenderNotFound(sender.id.clone().into());
+ let sender_deleted = || SendError::SenderDeleted(sender.id.clone().into());
let mut tx = self.db.begin().await?;
let conversation = tx
.conversations()
.by_id(conversation)
.await
- .not_found(to_not_found)?;
+ .not_found(conversation_not_found)?;
+ let sender = tx
+ .users()
+ .by_login(sender)
+ .await
+ .not_found(sender_not_found)?;
// Ordering: don't bother allocating a sequence number before we know the channel might
// exist.
let sent = tx.sequence().next(sent_at).await?;
- let conversation = conversation.as_of(sent).ok_or_else(to_deleted)?;
+ let conversation = conversation.as_of(sent).ok_or_else(conversation_deleted)?;
+ let sender = sender.as_of(sent).ok_or_else(sender_deleted)?;
let message = tx
.messages()
- .create(&conversation, sender, &sent, body)
+ .create(&conversation, &sender, &sent, body)
.await?;
tx.commit().await?;
@@ -57,36 +66,48 @@ impl<'a> Messages<'a> {
pub async fn delete(
&self,
- deleted_by: &User,
+ deleted_by: &Login,
message: &Id,
deleted_at: &DateTime,
) -> Result<(), DeleteError> {
+ let message_not_found = || DeleteError::MessageNotFound(message.clone());
+ let message_deleted = || DeleteError::Deleted(message.clone());
+ let deleter_not_found = || DeleteError::UserNotFound(deleted_by.id.clone().into());
+ let deleter_deleted = || DeleteError::UserDeleted(deleted_by.id.clone().into());
+ let not_sender = || DeleteError::NotSender(deleted_by.id.clone().into());
+
let mut tx = self.db.begin().await?;
let message = tx
.messages()
.by_id(message)
.await
- .not_found(|| DeleteError::NotFound(message.clone()))?;
- let snapshot = message
- .as_snapshot()
- .ok_or_else(|| DeleteError::Deleted(message.id().clone()))?;
- if snapshot.sender != deleted_by.id {
- return Err(DeleteError::NotSender(deleted_by.clone()));
- }
+ .not_found(message_not_found)?;
+ let deleted_by = tx
+ .users()
+ .by_login(deleted_by)
+ .await
+ .not_found(deleter_not_found)?;
let deleted = tx.sequence().next(deleted_at).await?;
- let message = tx.messages().delete(&message, &deleted).await?;
- tx.commit().await?;
+ let message = message.as_of(deleted).ok_or_else(message_deleted)?;
+ let deleted_by = deleted_by.as_of(deleted).ok_or_else(deleter_deleted)?;
- self.events.broadcast(
- message
- .events()
- .filter(Sequence::start_from(deleted.sequence))
- .map(Event::from)
- .collect::<Vec<_>>(),
- );
+ if message.sender == deleted_by.id {
+ let message = tx.messages().delete(&message, &deleted).await?;
+ tx.commit().await?;
- Ok(())
+ self.events.broadcast(
+ message
+ .events()
+ .filter(Sequence::start_from(deleted.sequence))
+ .map(Event::from)
+ .collect::<Vec<_>>(),
+ );
+
+ Ok(())
+ } else {
+ Err(not_sender())
+ }
}
pub async fn expire(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> {
@@ -99,12 +120,14 @@ impl<'a> Messages<'a> {
let mut events = Vec::with_capacity(expired.len());
for message in expired {
let deleted = tx.sequence().next(relative_to).await?;
- let message = tx.messages().delete(&message, &deleted).await?;
- events.push(
- message
- .events()
- .filter(Sequence::start_from(deleted.sequence)),
- );
+ if let Some(message) = message.as_of(deleted) {
+ let message = tx.messages().delete(&message, &deleted).await?;
+ events.push(
+ message
+ .events()
+ .filter(Sequence::start_from(deleted.sequence)),
+ );
+ }
}
tx.commit().await?;
@@ -138,6 +161,10 @@ pub enum SendError {
ConversationNotFound(conversation::Id),
#[error("conversation {0} deleted")]
ConversationDeleted(conversation::Id),
+ #[error("user {0} not found")]
+ SenderNotFound(user::Id),
+ #[error("user {0} deleted")]
+ SenderDeleted(user::Id),
#[error(transparent)]
Database(#[from] sqlx::Error),
#[error(transparent)]
@@ -154,14 +181,40 @@ impl From<conversation::repo::LoadError> for SendError {
}
}
+impl From<user::repo::LoadError> for SendError {
+ fn from(error: user::repo::LoadError) -> Self {
+ use user::repo::LoadError;
+ match error {
+ LoadError::Database(error) => error.into(),
+ LoadError::Name(error) => error.into(),
+ }
+ }
+}
+
#[derive(Debug, thiserror::Error)]
pub enum DeleteError {
#[error("message {0} not found")]
- NotFound(Id),
- #[error("user {} not the message's sender", .0.id)]
- NotSender(User),
+ MessageNotFound(Id),
+ #[error("user {0} not found")]
+ UserNotFound(user::Id),
+ #[error("user {0} deleted")]
+ UserDeleted(user::Id),
+ #[error("user {0} not the message's sender")]
+ NotSender(user::Id),
#[error("message {0} deleted")]
Deleted(Id),
#[error(transparent)]
Database(#[from] sqlx::Error),
+ #[error(transparent)]
+ Name(#[from] name::Error),
+}
+
+impl From<user::repo::LoadError> for DeleteError {
+ fn from(error: user::repo::LoadError) -> Self {
+ use user::repo::LoadError;
+ match error {
+ LoadError::Database(error) => error.into(),
+ LoadError::Name(error) => error.into(),
+ }
+ }
}
diff --git a/src/message/handlers/delete/mod.rs b/src/message/handlers/delete/mod.rs
index 5eac4eb..606f502 100644
--- a/src/message/handlers/delete/mod.rs
+++ b/src/message/handlers/delete/mod.rs
@@ -22,7 +22,7 @@ pub async fn handler(
identity: Identity,
) -> Result<Response, Error> {
app.messages()
- .delete(&identity.user, &message, &deleted_at)
+ .delete(&identity.login, &message, &deleted_at)
.await?;
Ok(Response { id: message })
@@ -48,8 +48,13 @@ impl IntoResponse for Error {
let Self(error) = self;
match error {
DeleteError::NotSender(_) => (StatusCode::FORBIDDEN, error.to_string()).into_response(),
- DeleteError::NotFound(_) | DeleteError::Deleted(_) => NotFound(error).into_response(),
- DeleteError::Database(_) => Internal::from(error).into_response(),
+ DeleteError::MessageNotFound(_) | DeleteError::Deleted(_) => {
+ NotFound(error).into_response()
+ }
+ DeleteError::UserNotFound(_)
+ | DeleteError::UserDeleted(_)
+ | DeleteError::Database(_)
+ | DeleteError::Name(_) => Internal::from(error).into_response(),
}
}
}
diff --git a/src/message/handlers/delete/test.rs b/src/message/handlers/delete/test.rs
index 371c7bf..d0e1794 100644
--- a/src/message/handlers/delete/test.rs
+++ b/src/message/handlers/delete/test.rs
@@ -11,7 +11,7 @@ pub async fn delete_message() {
let sender = fixtures::identity::create(&app, &fixtures::now()).await;
let conversation = fixtures::conversation::create(&app, &fixtures::now()).await;
let message =
- fixtures::message::send(&app, &conversation, &sender.user, &fixtures::now()).await;
+ fixtures::message::send(&app, &conversation, &sender.login, &fixtures::now()).await;
// Send the request
@@ -62,7 +62,7 @@ pub async fn delete_invalid_message_id() {
// Verify the response
- assert!(matches!(error, app::DeleteError::NotFound(id) if id == message));
+ assert!(matches!(error, app::DeleteError::MessageNotFound(id) if id == message));
}
#[tokio::test]
@@ -160,7 +160,7 @@ pub async fn delete_purged() {
// Verify the response
- assert!(matches!(error, app::DeleteError::NotFound(id) if id == message.id));
+ assert!(matches!(error, app::DeleteError::MessageNotFound(id) if id == message.id));
}
#[tokio::test]
@@ -187,6 +187,6 @@ pub async fn delete_not_sender() {
// Verify the response
assert!(
- matches!(error, app::DeleteError::NotSender(error_sender) if deleter.user == error_sender)
+ matches!(error, app::DeleteError::NotSender(error_sender) if deleter.login.id == error_sender)
);
}
diff --git a/src/message/history.rs b/src/message/history.rs
index d4d4500..2abdf2c 100644
--- a/src/message/history.rs
+++ b/src/message/history.rs
@@ -1,7 +1,7 @@
use itertools::Itertools as _;
use super::{
- Id, Message,
+ Message,
event::{Deleted, Event, Sent},
};
use crate::event::Sequence;
@@ -13,10 +13,6 @@ pub struct History {
// State interface
impl History {
- pub fn id(&self) -> &Id {
- &self.message.id
- }
-
// Snapshot of this message as it was when sent. (Note to the future: it's okay
// if this returns a redacted or modified version of the message. If we
// implement message editing by redacting the original body, then this should
@@ -26,6 +22,15 @@ impl History {
self.message.clone()
}
+ pub fn as_of<S>(&self, sequence: S) -> Option<Message>
+ where
+ S: Into<Sequence>,
+ {
+ self.events()
+ .filter(Sequence::up_to(sequence.into()))
+ .collect()
+ }
+
// Snapshot of this message as of all events recorded in this history.
pub fn as_snapshot(&self) -> Option<Message> {
self.events().collect()
diff --git a/src/message/repo.rs b/src/message/repo.rs
index 2e9700a..83bf0d5 100644
--- a/src/message/repo.rs
+++ b/src/message/repo.rs
@@ -180,17 +180,15 @@ impl Messages<'_> {
pub async fn delete(
&mut self,
- message: &History,
+ message: &Message,
deleted: &Instant,
) -> Result<History, sqlx::Error> {
- let id = message.id();
-
sqlx::query!(
r#"
insert into message_deleted (id, deleted_at, deleted_sequence)
values ($1, $2, $3)
"#,
- id,
+ message.id,
deleted.at,
deleted.sequence,
)
@@ -209,12 +207,12 @@ impl Messages<'_> {
returning id as "id: Id"
"#,
deleted.sequence,
- id,
+ message.id,
)
.fetch_one(&mut *self.0)
.await?;
- let message = self.by_id(id).await?;
+ let message = self.by_id(&message.id).await?;
Ok(message)
}
diff --git a/src/routes.rs b/src/routes.rs
index 6993070..5b9e15a 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -4,7 +4,7 @@ use axum::{
routing::{delete, get, post},
};
-use crate::{app::App, boot, conversation, event, expire, invite, message, setup, ui, user};
+use crate::{app::App, boot, conversation, event, expire, invite, login, message, setup, ui};
pub fn routes(app: &App) -> Router<App> {
// UI routes that can be accessed before the administrator completes setup.
@@ -27,8 +27,8 @@ pub fn routes(app: &App) -> Router<App> {
// API routes that require the administrator to complete setup first.
let api_setup_required = Router::new()
- .route("/api/auth/login", post(user::handlers::login))
- .route("/api/auth/logout", post(user::handlers::logout))
+ .route("/api/auth/login", post(login::handlers::login))
+ .route("/api/auth/logout", post(login::handlers::logout))
.route("/api/boot", get(boot::handlers::boot))
.route("/api/conversations", post(conversation::handlers::create))
.route(
@@ -44,7 +44,7 @@ pub fn routes(app: &App) -> Router<App> {
.route("/api/invite/{invite}", get(invite::handlers::get))
.route("/api/invite/{invite}", post(invite::handlers::accept))
.route("/api/messages/{message}", delete(message::handlers::delete))
- .route("/api/password", post(user::handlers::change_password))
+ .route("/api/password", post(login::handlers::change_password))
// Run expiry whenever someone accesses the API. This was previously a blanket middleware
// affecting the whole service, but loading the client makes a several requests before the
// client can completely load, each of which was triggering expiry. There is absolutely no
diff --git a/src/setup/app.rs b/src/setup/app.rs
index c1c53c5..2a8ec30 100644
--- a/src/setup/app.rs
+++ b/src/setup/app.rs
@@ -35,9 +35,9 @@ impl<'a> Setup<'a> {
Err(Error::SetupCompleted)
} else {
let stored = validated.store(&mut tx).await?;
- let user = stored.user().as_created();
+ let login = stored.login();
- let (token, secret) = Token::generate(&user, created_at);
+ let (token, secret) = Token::generate(login, created_at);
tx.tokens().create(&token, &secret).await?;
tx.commit().await?;
diff --git a/src/test/fixtures/cookie.rs b/src/test/fixtures/cookie.rs
index f5a32a6..7dc5083 100644
--- a/src/test/fixtures/cookie.rs
+++ b/src/test/fixtures/cookie.rs
@@ -1,11 +1,7 @@
use uuid::Uuid;
use crate::{
- app::App,
- clock::RequestedAt,
- name::Name,
- password::Password,
- token::{Secret, extract::IdentityCookie},
+ app::App, clock::RequestedAt, name::Name, password::Password, token::extract::IdentityCookie,
};
pub fn not_logged_in() -> IdentityCookie {
@@ -19,18 +15,14 @@ pub async fn logged_in(
) -> IdentityCookie {
let (name, password) = credentials;
let secret = app
- .tokens()
- .login(name, password, now)
+ .logins()
+ .with_password(name, password, now)
.await
.expect("should succeed given known-valid credentials");
IdentityCookie::new().set(secret)
}
-pub fn secret(identity: &IdentityCookie) -> Secret {
- identity.secret().expect("identity contained a secret")
-}
-
pub fn fictitious() -> IdentityCookie {
let token = Uuid::new_v4().to_string();
IdentityCookie::new().set(token)
diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs
index ac353de..93e4a38 100644
--- a/src/test/fixtures/identity.rs
+++ b/src/test/fixtures/identity.rs
@@ -37,8 +37,8 @@ pub async fn logged_in(
}
pub fn fictitious() -> Identity {
- let user = fixtures::user::fictitious();
- let (token, _) = Token::generate(&user, &fixtures::now());
+ let login = fixtures::login::fictitious();
+ let (token, _) = Token::generate(&login, &fixtures::now());
- Identity { token, user }
+ Identity { token, login }
}
diff --git a/src/test/fixtures/invite.rs b/src/test/fixtures/invite.rs
index 7a41eb6..654d1b4 100644
--- a/src/test/fixtures/invite.rs
+++ b/src/test/fixtures/invite.rs
@@ -2,10 +2,10 @@ use crate::{
app::App,
clock::DateTime,
invite::{self, Invite},
- user::User,
+ login::Login,
};
-pub async fn issue(app: &App, issuer: &User, issued_at: &DateTime) -> Invite {
+pub async fn issue(app: &App, issuer: &Login, issued_at: &DateTime) -> Invite {
app.invites()
.issue(issuer, issued_at)
.await
diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs
new file mode 100644
index 0000000..d9aca81
--- /dev/null
+++ b/src/test/fixtures/login.rs
@@ -0,0 +1,21 @@
+use crate::{
+ app::App,
+ clock::DateTime,
+ login::{self, Login},
+ test::fixtures::user::{propose, propose_name},
+};
+
+pub async fn create(app: &App, created_at: &DateTime) -> Login {
+ let (name, password) = propose();
+ app.users()
+ .create(&name, &password, created_at)
+ .await
+ .expect("should always succeed if the user is actually new")
+}
+
+pub fn fictitious() -> Login {
+ Login {
+ id: login::Id::generate(),
+ name: propose_name(),
+ }
+}
diff --git a/src/test/fixtures/message.rs b/src/test/fixtures/message.rs
index 03f8072..92ac1f5 100644
--- a/src/test/fixtures/message.rs
+++ b/src/test/fixtures/message.rs
@@ -4,14 +4,14 @@ use crate::{
app::App,
clock::RequestedAt,
conversation::Conversation,
+ login::Login,
message::{self, Body, Message},
- user::User,
};
pub async fn send(
app: &App,
conversation: &Conversation,
- sender: &User,
+ sender: &Login,
sent_at: &RequestedAt,
) -> Message {
let body = propose();
diff --git a/src/test/fixtures/mod.rs b/src/test/fixtures/mod.rs
index 87d3fa1..3d69cfa 100644
--- a/src/test/fixtures/mod.rs
+++ b/src/test/fixtures/mod.rs
@@ -9,6 +9,7 @@ pub mod event;
pub mod future;
pub mod identity;
pub mod invite;
+pub mod login;
pub mod message;
pub mod user;
diff --git a/src/test/fixtures/user.rs b/src/test/fixtures/user.rs
index 086f866..d4d8db4 100644
--- a/src/test/fixtures/user.rs
+++ b/src/test/fixtures/user.rs
@@ -1,13 +1,7 @@
use faker_rand::{en_us::internet, lorem::Paragraphs};
use uuid::Uuid;
-use crate::{
- app::App,
- clock::RequestedAt,
- name::Name,
- password::Password,
- user::{self, User},
-};
+use crate::{app::App, clock::RequestedAt, login::Login, name::Name, password::Password};
pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name, Password) {
let (name, password) = propose();
@@ -20,19 +14,8 @@ pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name,
(user.name, password)
}
-pub async fn create(app: &App, created_at: &RequestedAt) -> User {
- let (name, password) = propose();
- app.users()
- .create(&name, &password, created_at)
- .await
- .expect("should always succeed if the login is actually new")
-}
-
-pub fn fictitious() -> User {
- User {
- id: user::Id::generate(),
- name: propose_name(),
- }
+pub async fn create(app: &App, created_at: &RequestedAt) -> Login {
+ super::login::create(app, created_at).await
}
pub fn propose() -> (Name, Password) {
@@ -43,7 +26,7 @@ pub fn propose_invalid_name() -> Name {
rand::random::<Paragraphs>().to_string().into()
}
-fn propose_name() -> Name {
+pub(crate) fn propose_name() -> Name {
rand::random::<internet::Username>().to_string().into()
}
diff --git a/src/test/verify/identity.rs b/src/test/verify/identity.rs
index 226ee74..8e2d36e 100644
--- a/src/test/verify/identity.rs
+++ b/src/test/verify/identity.rs
@@ -1,9 +1,9 @@
use crate::{
app::App,
+ login::Login,
name::Name,
test::{fixtures, verify},
token::{app::ValidateError, extract::IdentityCookie},
- user::User,
};
pub async fn valid_for_name(app: &App, identity: &IdentityCookie, name: &Name) {
@@ -13,11 +13,11 @@ pub async fn valid_for_name(app: &App, identity: &IdentityCookie, name: &Name) {
verify::token::valid_for_name(app, &secret, name).await;
}
-pub async fn valid_for_user(app: &App, identity: &IdentityCookie, user: &User) {
+pub async fn valid_for_login(app: &App, identity: &IdentityCookie, login: &Login) {
let secret = identity
.secret()
.expect("identity cookie must be set to be valid");
- verify::token::valid_for_user(app, &secret, user).await;
+ verify::token::valid_for_login(app, &secret, login).await;
}
pub async fn invalid(app: &App, identity: &IdentityCookie) {
diff --git a/src/test/verify/login.rs b/src/test/verify/login.rs
index 3f291a3..ae2e91e 100644
--- a/src/test/verify/login.rs
+++ b/src/test/verify/login.rs
@@ -1,15 +1,15 @@
use crate::{
app::App,
+ login::app::LoginError,
name::Name,
password::Password,
test::{fixtures, verify},
- token::app::LoginError,
};
pub async fn valid_login(app: &App, name: &Name, password: &Password) {
let secret = app
- .tokens()
- .login(&name, &password, &fixtures::now())
+ .logins()
+ .with_password(name, password, &fixtures::now())
.await
.expect("login credentials expected to be valid");
verify::token::valid_for_name(&app, &secret, &name).await;
@@ -17,8 +17,8 @@ pub async fn valid_login(app: &App, name: &Name, password: &Password) {
pub async fn invalid_login(app: &App, name: &Name, password: &Password) {
let error = app
- .tokens()
- .login(name, password, &fixtures::now())
+ .logins()
+ .with_password(name, password, &fixtures::now())
.await
.expect_err("login credentials expected not to be valid");
assert!(matches!(error, LoginError::Rejected));
diff --git a/src/test/verify/token.rs b/src/test/verify/token.rs
index 99cd31f..adc4397 100644
--- a/src/test/verify/token.rs
+++ b/src/test/verify/token.rs
@@ -1,9 +1,9 @@
use crate::{
app::App,
+ login::Login,
name::Name,
test::fixtures,
token::{Secret, app},
- user::User,
};
pub async fn valid_for_name(app: &App, secret: &Secret, name: &Name) {
@@ -12,16 +12,16 @@ pub async fn valid_for_name(app: &App, secret: &Secret, name: &Name) {
.validate(secret, &fixtures::now())
.await
.expect("provided secret is valid");
- assert_eq!(name, &identity.user.name);
+ assert_eq!(name, &identity.login.name);
}
-pub async fn valid_for_user(app: &App, secret: &Secret, user: &User) {
+pub async fn valid_for_login(app: &App, secret: &Secret, login: &Login) {
let identity = app
.tokens()
.validate(secret, &fixtures::now())
.await
.expect("provided secret is valid");
- assert_eq!(user, &identity.user);
+ assert_eq!(login, &identity.login);
}
pub async fn invalid(app: &App, secret: &Secret) {
diff --git a/src/token/app.rs b/src/token/app.rs
index a7a843d..fb5d712 100644
--- a/src/token/app.rs
+++ b/src/token/app.rs
@@ -8,15 +8,9 @@ use sqlx::sqlite::SqlitePool;
use super::{
Broadcaster, Event as TokenEvent, Secret, Token,
extract::Identity,
- repo::{self, Provider as _, auth::Provider as _},
-};
-use crate::{
- clock::DateTime,
- db::NotFound as _,
- name::{self, Name},
- password::Password,
- user::{User, repo::Provider as _},
+ repo::{self, Provider as _},
};
+use crate::{clock::DateTime, db::NotFound as _, name};
pub struct Tokens<'a> {
db: &'a SqlitePool,
@@ -28,100 +22,20 @@ impl<'a> Tokens<'a> {
Self { db, token_events }
}
- pub async fn login(
- &self,
- name: &Name,
- password: &Password,
- login_at: &DateTime,
- ) -> Result<Secret, LoginError> {
- let mut tx = self.db.begin().await?;
- let (user, stored_hash) = tx
- .auth()
- .for_name(name)
- .await
- .optional()?
- .ok_or(LoginError::Rejected)?;
- // Split the transaction here to avoid holding the tx open (potentially blocking
- // other writes) while we do the fairly expensive task of verifying the
- // password. It's okay if the token issuance transaction happens some notional
- // amount of time after retrieving the login, as inserting the token will fail
- // if the account is deleted during that time.
- tx.commit().await?;
-
- let user = user.as_snapshot().ok_or(LoginError::Rejected)?;
-
- if stored_hash.verify(password)? {
- let mut tx = self.db.begin().await?;
- let (token, secret) = Token::generate(&user, login_at);
- tx.tokens().create(&token, &secret).await?;
- tx.commit().await?;
-
- Ok(secret)
- } else {
- Err(LoginError::Rejected)
- }
- }
-
- pub async fn change_password(
- &self,
- user: &User,
- password: &Password,
- to: &Password,
- changed_at: &DateTime,
- ) -> Result<Secret, LoginError> {
- let mut tx = self.db.begin().await?;
- let (user, stored_hash) = tx
- .auth()
- .for_user(user)
- .await
- .optional()?
- .ok_or(LoginError::Rejected)?;
- // Split the transaction here to avoid holding the tx open (potentially blocking
- // other writes) while we do the fairly expensive task of verifying the
- // password. It's okay if the token issuance transaction happens some notional
- // amount of time after retrieving the login, as inserting the token will fail
- // if the account is deleted during that time.
- tx.commit().await?;
-
- if !stored_hash.verify(password)? {
- return Err(LoginError::Rejected);
- }
-
- let user_snapshot = user.as_snapshot().ok_or(LoginError::Rejected)?;
- let to_hash = to.hash()?;
-
- let mut tx = self.db.begin().await?;
- tx.users().set_password(&user, &to_hash).await?;
-
- let tokens = tx.tokens().revoke_all(&user).await?;
- let (token, secret) = Token::generate(&user_snapshot, changed_at);
- tx.tokens().create(&token, &secret).await?;
-
- tx.commit().await?;
-
- for event in tokens.into_iter().map(TokenEvent::Revoked) {
- self.token_events.broadcast(event);
- }
-
- Ok(secret)
- }
-
pub async fn validate(
&self,
secret: &Secret,
used_at: &DateTime,
) -> Result<Identity, ValidateError> {
let mut tx = self.db.begin().await?;
- let (token, user) = tx
+ let (token, login) = tx
.tokens()
.validate(secret, used_at)
.await
.not_found(|| ValidateError::InvalidToken)?;
tx.commit().await?;
- let user = user.as_snapshot().ok_or(ValidateError::LoginDeleted)?;
-
- Ok(Identity { token, user })
+ Ok(Identity { token, login })
}
pub async fn limit_stream<S, E>(
@@ -208,33 +122,9 @@ impl<'a> Tokens<'a> {
}
#[derive(Debug, thiserror::Error)]
-pub enum LoginError {
- #[error("invalid login")]
- Rejected,
- #[error(transparent)]
- Database(#[from] sqlx::Error),
- #[error(transparent)]
- Name(#[from] name::Error),
- #[error(transparent)]
- PasswordHash(#[from] password_hash::Error),
-}
-
-impl From<repo::auth::LoadError> for LoginError {
- fn from(error: repo::auth::LoadError) -> Self {
- use repo::auth::LoadError;
- match error {
- LoadError::Database(error) => error.into(),
- LoadError::Name(error) => error.into(),
- }
- }
-}
-
-#[derive(Debug, thiserror::Error)]
pub enum ValidateError {
#[error("invalid token")]
InvalidToken,
- #[error("user deleted")]
- LoginDeleted,
#[error(transparent)]
Database(#[from] sqlx::Error),
#[error(transparent)]
diff --git a/src/token/extract/identity.rs b/src/token/extract/identity.rs
index d01ab53..960fe60 100644
--- a/src/token/extract/identity.rs
+++ b/src/token/extract/identity.rs
@@ -10,14 +10,14 @@ use crate::{
app::App,
clock::RequestedAt,
error::{Internal, Unauthorized},
+ login::Login,
token::{Token, app::ValidateError},
- user::User,
};
#[derive(Clone, Debug)]
pub struct Identity {
pub token: Token,
- pub user: User,
+ pub login: Login,
}
impl FromRequestParts<App> for Identity {
diff --git a/src/token/mod.rs b/src/token/mod.rs
index 58ff08b..b2dd6f1 100644
--- a/src/token/mod.rs
+++ b/src/token/mod.rs
@@ -8,30 +8,26 @@ mod secret;
use uuid::Uuid;
-use crate::{
- clock::DateTime,
- user::{self, User},
-};
-
pub use self::{broadcaster::Broadcaster, event::Event, id::Id, secret::Secret};
+use crate::{clock::DateTime, login, login::Login};
#[derive(Clone, Debug)]
pub struct Token {
pub id: Id,
- pub user: user::Id,
+ pub login: login::Id,
pub issued_at: DateTime,
pub last_used_at: DateTime,
}
impl Token {
- pub fn generate(user: &User, issued_at: &DateTime) -> (Self, Secret) {
+ pub fn generate(login: &Login, issued_at: &DateTime) -> (Self, Secret) {
let id = Id::generate();
let secret = Uuid::new_v4().to_string().into();
(
Self {
id,
- user: user.id.clone(),
+ login: login.id.clone(),
issued_at: *issued_at,
last_used_at: *issued_at,
},
diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs
deleted file mode 100644
index a42fa1a..0000000
--- a/src/token/repo/auth.rs
+++ /dev/null
@@ -1,105 +0,0 @@
-use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite};
-
-use crate::{
- clock::DateTime,
- db::NotFound,
- event::{Instant, Sequence},
- name::{self, Name},
- password::StoredHash,
- user::{self, History, User},
-};
-
-pub trait Provider {
- fn auth(&mut self) -> Auth<'_>;
-}
-
-impl Provider for Transaction<'_, Sqlite> {
- fn auth(&mut self) -> Auth<'_> {
- Auth(self)
- }
-}
-
-pub struct Auth<'t>(&'t mut SqliteConnection);
-
-impl Auth<'_> {
- pub async fn for_name(&mut self, name: &Name) -> Result<(History, StoredHash), LoadError> {
- let name = name.canonical();
- let row = sqlx::query!(
- r#"
- select
- id as "id: user::Id",
- login.display_name as "display_name: String",
- login.canonical_name as "canonical_name: String",
- user.created_sequence as "created_sequence: Sequence",
- user.created_at as "created_at: DateTime",
- login.password as "password: StoredHash"
- from user
- join login using (id)
- where login.canonical_name = $1
- "#,
- name,
- )
- .fetch_one(&mut *self.0)
- .await?;
-
- let login = History {
- user: User {
- id: row.id,
- name: Name::new(row.display_name, row.canonical_name)?,
- },
- created: Instant::new(row.created_at, row.created_sequence),
- };
-
- Ok((login, row.password))
- }
-
- pub async fn for_user(&mut self, user: &User) -> Result<(History, StoredHash), LoadError> {
- let row = sqlx::query!(
- r#"
- select
- id as "id: user::Id",
- login.display_name as "display_name: String",
- login.canonical_name as "canonical_name: String",
- user.created_sequence as "created_sequence: Sequence",
- user.created_at as "created_at: DateTime",
- login.password as "password: StoredHash"
- from user
- join login using (id)
- where id = $1
- "#,
- user.id,
- )
- .fetch_one(&mut *self.0)
- .await?;
-
- let user = History {
- user: User {
- id: row.id,
- name: Name::new(row.display_name, row.canonical_name)?,
- },
- created: Instant::new(row.created_at, row.created_sequence),
- };
-
- Ok((user, row.password))
- }
-}
-
-#[derive(Debug, thiserror::Error)]
-#[error(transparent)]
-pub enum LoadError {
- Database(#[from] sqlx::Error),
- Name(#[from] name::Error),
-}
-
-impl<T> NotFound for Result<T, LoadError> {
- type Ok = T;
- type Error = LoadError;
-
- fn optional(self) -> Result<Option<T>, LoadError> {
- match self {
- Ok(value) => Ok(Some(value)),
- Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None),
- Err(other) => Err(other),
- }
- }
-}
diff --git a/src/token/repo/mod.rs b/src/token/repo/mod.rs
index d8463eb..9df5bbb 100644
--- a/src/token/repo/mod.rs
+++ b/src/token/repo/mod.rs
@@ -1,4 +1,3 @@
-pub mod auth;
mod token;
pub use self::token::{LoadError, Provider};
diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs
index afcde53..52a3987 100644
--- a/src/token/repo/token.rs
+++ b/src/token/repo/token.rs
@@ -3,10 +3,9 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite};
use crate::{
clock::DateTime,
db::NotFound,
- event::{Instant, Sequence},
+ login::{self, Login},
name::{self, Name},
token::{Id, Secret, Token},
- user::{self, History, User},
};
pub trait Provider {
@@ -31,11 +30,11 @@ impl Tokens<'_> {
"#,
token.id,
secret,
- token.user,
+ token.login,
token.issued_at,
token.last_used_at,
)
- .fetch_one(&mut *self.0)
+ .execute(&mut *self.0)
.await?;
Ok(())
@@ -71,8 +70,7 @@ impl Tokens<'_> {
}
// Revoke tokens for a login
- pub async fn revoke_all(&mut self, user: &user::History) -> Result<Vec<Id>, sqlx::Error> {
- let user = user.id();
+ pub async fn revoke_all(&mut self, login: &Login) -> Result<Vec<Id>, sqlx::Error> {
let tokens = sqlx::query_scalar!(
r#"
delete
@@ -80,7 +78,7 @@ impl Tokens<'_> {
where login = $1
returning id as "id: Id"
"#,
- user,
+ login.id,
)
.fetch_all(&mut *self.0)
.await?;
@@ -106,14 +104,11 @@ impl Tokens<'_> {
Ok(tokens)
}
- // Validate a token by its secret, retrieving the associated Login record.
- // Will return an error if the token is not valid. If successful, the
- // retrieved token's last-used timestamp will be set to `used_at`.
pub async fn validate(
&mut self,
secret: &Secret,
used_at: &DateTime,
- ) -> Result<(Token, History), LoadError> {
+ ) -> Result<(Token, Login), LoadError> {
// I would use `update … returning` to do this in one query, but
// sqlite3, as of this writing, does not allow an update's `returning`
// clause to reference columns from tables joined into the update. Two
@@ -125,7 +120,7 @@ impl Tokens<'_> {
where secret = $2
returning
id as "id: Id",
- login as "login: user::Id",
+ login as "login: login::Id",
issued_at as "issued_at: DateTime",
last_used_at as "last_used_at: DateTime"
"#,
@@ -134,7 +129,7 @@ impl Tokens<'_> {
)
.map(|row| Token {
id: row.id,
- user: row.login,
+ login: row.login,
issued_at: row.issued_at,
last_used_at: row.last_used_at,
})
@@ -144,24 +139,18 @@ impl Tokens<'_> {
let user = sqlx::query!(
r#"
select
- id as "id: user::Id",
- login.display_name as "display_name: String",
- login.canonical_name as "canonical_name: String",
- user.created_sequence as "created_sequence: Sequence",
- user.created_at as "created_at: DateTime"
- from user
- join login using (id)
+ id as "id: login::Id",
+ display_name,
+ canonical_name
+ from login
where id = $1
"#,
- token.user,
+ token.login,
)
.map(|row| {
- Ok::<_, name::Error>(History {
- user: User {
- id: row.id,
- name: Name::new(row.display_name, row.canonical_name)?,
- },
- created: Instant::new(row.created_at, row.created_sequence),
+ Ok::<_, name::Error>(Login {
+ id: row.id,
+ name: Name::new(row.display_name, row.canonical_name)?,
})
})
.fetch_one(&mut *self.0)
diff --git a/src/user/app.rs b/src/user/app.rs
index 301c39c..0d6046c 100644
--- a/src/user/app.rs
+++ b/src/user/app.rs
@@ -1,10 +1,7 @@
use sqlx::sqlite::SqlitePool;
-use super::{
- User,
- create::{self, Create},
-};
-use crate::{clock::DateTime, event::Broadcaster, name::Name, password::Password};
+use super::create::{self, Create};
+use crate::{clock::DateTime, event::Broadcaster, login::Login, name::Name, password::Password};
pub struct Users<'a> {
db: &'a SqlitePool,
@@ -21,7 +18,7 @@ impl<'a> Users<'a> {
name: &Name,
password: &Password,
created_at: &DateTime,
- ) -> Result<User, CreateError> {
+ ) -> Result<Login, CreateError> {
let create = Create::begin(name, password, created_at);
let validated = create.validate()?;
@@ -29,10 +26,10 @@ impl<'a> Users<'a> {
let stored = validated.store(&mut tx).await?;
tx.commit().await?;
- let user = stored.user().to_owned();
+ let login = stored.login().to_owned();
stored.publish(self.events);
- Ok(user.as_created())
+ Ok(login)
}
}
@@ -46,7 +43,6 @@ pub enum CreateError {
Database(#[from] sqlx::Error),
}
-#[cfg(test)]
impl From<create::Error> for CreateError {
fn from(error: create::Error) -> Self {
match error {
diff --git a/src/user/create.rs b/src/user/create.rs
index 5d7bf65..5c060c9 100644
--- a/src/user/create.rs
+++ b/src/user/create.rs
@@ -4,6 +4,7 @@ use super::{History, repo::Provider as _, validate};
use crate::{
clock::DateTime,
event::{Broadcaster, Event, repo::Provider as _},
+ login::{self, Login, repo::Provider as _},
name::Name,
password::{Password, StoredHash},
};
@@ -39,7 +40,7 @@ impl<'a> Create<'a> {
Ok(Validated {
name,
- password_hash,
+ password: password_hash,
created_at,
})
}
@@ -48,7 +49,7 @@ impl<'a> Create<'a> {
#[must_use = "dropping a user creation attempt is likely a mistake"]
pub struct Validated<'a> {
name: &'a Name,
- password_hash: StoredHash,
+ password: StoredHash,
created_at: &'a DateTime,
}
@@ -56,31 +57,38 @@ impl Validated<'_> {
pub async fn store(self, tx: &mut Transaction<'_, Sqlite>) -> Result<Stored, sqlx::Error> {
let Self {
name,
- password_hash,
+ password,
created_at,
} = self;
+ let login = Login {
+ id: login::Id::generate(),
+ name: name.to_owned(),
+ };
+
let created = tx.sequence().next(created_at).await?;
- let user = tx.users().create(name, &password_hash, &created).await?;
+ tx.logins().create(&login, &password).await?;
+ let user = tx.users().create(&login, &created).await?;
- Ok(Stored { user })
+ Ok(Stored { user, login })
}
}
#[must_use = "dropping a user creation attempt is likely a mistake"]
pub struct Stored {
user: History,
+ login: Login,
}
impl Stored {
- pub fn publish(self, events: &Broadcaster) {
- let Self { user } = self;
+ pub fn publish(self, broadcaster: &Broadcaster) {
+ let Self { user, login: _ } = self;
- events.broadcast(user.events().map(Event::from).collect::<Vec<_>>());
+ broadcaster.broadcast(user.events().map(Event::from).collect::<Vec<_>>());
}
- pub fn user(&self) -> &History {
- &self.user
+ pub fn login(&self) -> &Login {
+ &self.login
}
}
diff --git a/src/user/history.rs b/src/user/history.rs
index 72e0aee..f58e9c7 100644
--- a/src/user/history.rs
+++ b/src/user/history.rs
@@ -1,8 +1,8 @@
use super::{
- Id, User,
+ User,
event::{Created, Event},
};
-use crate::event::Instant;
+use crate::event::{Instant, Sequence};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct History {
@@ -12,21 +12,13 @@ pub struct History {
// State interface
impl History {
- pub fn id(&self) -> &Id {
- &self.user.id
- }
-
- // Snapshot of this user as it was when created. (Note to the future: it's okay
- // if this returns a redacted or modified version of the user. If we implement
- // renames by redacting the original name, then this should return the edited
- // user, not the original, even if that's not how it was "as created.")
- pub fn as_created(&self) -> User {
- self.user.clone()
- }
-
- // Snapshot of this user, as of all events recorded in this history.
- pub fn as_snapshot(&self) -> Option<User> {
- self.events().collect()
+ pub fn as_of<S>(&self, sequence: S) -> Option<User>
+ where
+ S: Into<Sequence>,
+ {
+ self.events()
+ .filter(Sequence::up_to(sequence.into()))
+ .collect()
}
}
diff --git a/src/user/id.rs b/src/user/id.rs
index 3ad8d16..ceb310a 100644
--- a/src/user/id.rs
+++ b/src/user/id.rs
@@ -1,7 +1,24 @@
+use crate::login;
+
// Stable identifier for a User. Prefixed with `U`. Users created before March, 2025 may have an `L`
// prefix, instead.
pub type Id = crate::id::Id<User>;
+// Login IDs and user IDs are typewise-distinct as they identify things in different namespaces, but
+// in practice a login and its associated user _must_ have IDs that encode to the same value. The
+// two ID types are made interconvertible (via `From`) for this purpose.
+impl From<login::Id> for Id {
+ fn from(login: login::Id) -> Self {
+ Self::from(String::from(login))
+ }
+}
+
+impl PartialEq<login::Id> for Id {
+ fn eq(&self, other: &login::Id) -> bool {
+ self.as_str().eq(other.as_str())
+ }
+}
+
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct User;
diff --git a/src/user/mod.rs b/src/user/mod.rs
index 60ec209..95bec2f 100644
--- a/src/user/mod.rs
+++ b/src/user/mod.rs
@@ -2,9 +2,8 @@
pub mod app;
pub mod create;
pub mod event;
-pub mod handlers;
mod history;
-mod id;
+pub mod id;
pub mod repo;
mod snapshot;
mod validate;
diff --git a/src/user/repo.rs b/src/user/repo.rs
index bfb603d..aaf3b73 100644
--- a/src/user/repo.rs
+++ b/src/user/repo.rs
@@ -3,9 +3,10 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite};
use crate::{
clock::DateTime,
+ db::NotFound,
event::{Instant, Sequence},
+ login::Login,
name::{self, Name},
- password::StoredHash,
user::{History, Id, User},
};
@@ -24,71 +25,58 @@ pub struct Users<'t>(&'t mut SqliteConnection);
impl Users<'_> {
pub async fn create(
&mut self,
- name: &Name,
- password: &StoredHash,
+ login: &Login,
created: &Instant,
) -> Result<History, sqlx::Error> {
- let id = Id::generate();
- let display_name = name.display();
- let canonical_name = name.canonical();
-
- sqlx::query!(
- r#"
- insert into login (id, display_name, canonical_name, password)
- values ($1, $2, $3, $4)
- "#,
- id,
- display_name,
- canonical_name,
- password,
- )
- .execute(&mut *self.0)
- .await?;
-
sqlx::query!(
r#"
insert into user (id, created_sequence, created_at)
values ($1, $2, $3)
"#,
- id,
+ login.id,
created.sequence,
created.at,
)
.execute(&mut *self.0)
.await?;
- let user = History {
- created: *created,
+ Ok(History {
user: User {
- id,
- name: name.clone(),
+ id: login.id.clone().into(),
+ name: login.name.clone(),
},
- };
-
- Ok(user)
+ created: *created,
+ })
}
- pub async fn set_password(
- &mut self,
- login: &History,
- to: &StoredHash,
- ) -> Result<(), sqlx::Error> {
- let login = login.id();
-
- sqlx::query_scalar!(
+ pub async fn by_login(&mut self, login: &Login) -> Result<History, LoadError> {
+ let user = sqlx::query!(
r#"
- update login
- set password = $1
- where id = $2
- returning id as "id: Id"
+ select
+ id as "id: Id",
+ login.display_name as "display_name: String",
+ login.canonical_name as "canonical_name: String",
+ user.created_at as "created_at: DateTime",
+ user.created_sequence as "created_sequence: Sequence"
+ from user
+ join login using (id)
+ where id = $1
"#,
- to,
- login,
+ login.id,
)
+ .map(|row| {
+ Ok::<_, LoadError>(History {
+ user: User {
+ id: row.id,
+ name: Name::new(row.display_name, row.canonical_name)?,
+ },
+ created: Instant::new(row.created_at, row.created_sequence),
+ })
+ })
.fetch_one(&mut *self.0)
- .await?;
+ .await??;
- Ok(())
+ Ok(user)
}
pub async fn all(&mut self, resume_at: Sequence) -> Result<Vec<History>, LoadError> {
@@ -163,3 +151,16 @@ pub enum LoadError {
Database(#[from] sqlx::Error),
Name(#[from] name::Error),
}
+
+impl<T> NotFound for Result<T, LoadError> {
+ type Ok = T;
+ type Error = LoadError;
+
+ fn optional(self) -> Result<Option<T>, LoadError> {
+ match self {
+ Ok(value) => Ok(Some(value)),
+ Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None),
+ Err(other) => Err(other),
+ }
+ }
+}
diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js
index 42b86f0..c415d0c 100644
--- a/ui/lib/session.svelte.js
+++ b/ui/lib/session.svelte.js
@@ -62,9 +62,9 @@ class Session {
),
);
- static boot({ user, resume_point, heartbeat, events }) {
+ static boot({ login, resume_point, heartbeat, events }) {
const remote = r.State.boot({
- currentUser: user,
+ currentUser: login,
resumePoint: resume_point,
heartbeat,
events,
@@ -73,9 +73,9 @@ class Session {
return new Session(remote, local);
}
- reboot({ user, resume_point, heartbeat, events }) {
+ reboot({ login, resume_point, heartbeat, events }) {
this.remote = r.State.boot({
- currentUser: user,
+ currentUser: login,
resumePoint: resume_point,
heartbeat,
events,