summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorojacobson <ojacobson@noreply.codeberg.org>2025-08-26 23:36:40 +0200
committerojacobson <ojacobson@noreply.codeberg.org>2025-08-26 23:36:40 +0200
commit7b131e35fdea1a68aaf9230d157bafb200557ef8 (patch)
treeb0f3ee3ac604947a8866c692a080d3f6064d7d03
parent68f54c8904ec6ff2ac3be4c514fa4aa05a67cb68 (diff)
parentd0d5fa20200a7ad70173ba87ae47c33b60f44a3b (diff)
Split `user` into a chat-facing entity and an authentication-facing entity.
The taxonomy is now as follows: * A _login_ is someone's identity for the purposes of authenticating to the service. Logins are not synchronized, and in fact are not published anywhere in the current API. They have a login ID, a name and a password. * A _user_ is someone's identity for the purpose of participating in conversations. Users _are_ synchronized, as before. They have a user ID, a name, and a creation instant for the purposes of synchronization. ## API changes * `GET /api/boot` method now returns a `login` key instead of a `user` key. The structure of the nested value is unchanged. This change is not backwards-compatible; the included client and the docs have been updated accordingly. ## Server implementation * Most app methods that took a `&User` as an identity now take a `&Login` as an identity, instead. Where a `User` is needed, the new `tx.users().for_login(&login)` database access method resolves a `Login` to its corresponding `user::History`, which can then be turned into a `User` at whatever point in time is most appropriate. This adds a few new error cases to methods that traverse the login-to-history-to-user chain. Those cases are presently unreachable, but I've fully fleshed them out so that they don't bite us later. Most of the resulting errors, however, are captured as internal server errors. * There is a new `app.logins()` application entry point, dealing with login identities and password-based logins. * `app.tokens()` is a bit more limited in scope to only things that work with an existing token. That has the side effect of splitting up logging in (in `app.logins().with_password(…)`) and logging out (in `app.tokens().logout(…)`). ## Schema changes The `user` table has been split: * `login` holds the data needed for the user to log in - their login ID, their name, and their password. * `user` now holds only the user ID and the event data for the user's `created` instant. Reconstructing a `User` struct requires joining in data from both `login` and `user`. In theory, the relationship is one-way: every user has a login. In practice, it's reciprocal: every login has a user and every user has a login. Relationships with downstream tables have been modified to suit: * `message` still refers to `user` for authorship information. * `invite` still refers to `user` for originator information. * `token` refers to `login` for authentication information. ## Blimy, that's big Yeah, I know. It's hard to avoid and I'm not sure the effort of making this in incremental steps is worth it. Authentication logic has a way of getting into all sorts of corners, and Pilcrow is no different. In order for the new taxonomy to make sense, all of the places that previously used `User` as a representation of an authenticated identity have to be updated, and it's easier to do that all at once, so that we can retire all the code that _supports_ using a `User` that way. Merges split-user into main.
-rw-r--r--.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json38
-rw-r--r--.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json12
-rw-r--r--.sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json (renamed from .sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json)4
-rw-r--r--.sqlx/query-0be9484c8d277b08a925b7776b5d0e847cf52c68f24f5ea878b897944e70254c.json20
-rw-r--r--.sqlx/query-27c3ffb5c284cf2d1c7cee713f6354d6150088a052d1f261853f327a83e8dd75.json20
-rw-r--r--.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json12
-rw-r--r--.sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json38
-rw-r--r--.sqlx/query-613199c25e16a52d85761e213750fe202abfa2af5347010dee59d3b8075eb19e.json20
-rw-r--r--.sqlx/query-684b4bef3e553b8ccfa20d8de3272e44b1c15ed75977ce24f25fd62042fb20fb.json26
-rw-r--r--.sqlx/query-79495da0101ad12e7e983666b2ca84cbe31458ff73a94648c30b62f3130a5a8b.json50
-rw-r--r--.sqlx/query-7c87864e6d077640e4f3ef4a354a47265238010f4c53d3c5982f4fd95ed1f6d0.json12
-rw-r--r--.sqlx/query-8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c.json (renamed from .sqlx/query-9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2.json)4
-rw-r--r--.sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json12
-rw-r--r--.sqlx/query-963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477.json (renamed from .sqlx/query-53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183.json)4
-rw-r--r--.sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json32
-rw-r--r--.sqlx/query-afc6db503a3c49c18a9cb07cf0360789d46d94d9cd98887a3d9660d9b753d416.json50
-rw-r--r--.sqlx/query-be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa.json (renamed from .sqlx/query-9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281.json)6
-rw-r--r--.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json (renamed from .sqlx/query-9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454.json)12
-rw-r--r--.sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json38
-rw-r--r--.sqlx/query-f9a6a39c45c3b039f139da0475c25112ffb2b26584ada60c324fc2f945c3d2fa.json12
-rw-r--r--.sqlx/query-f9abb172f96bff3a5fb4ad29f3a52be8d3134fc68e37c6074c69970604ae3844.json12
-rw-r--r--docs/api/boot.md9
-rw-r--r--migrations/20250822213537_split_user.sql195
-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/mod.rs2
-rw-r--r--src/event/handlers/stream/test/invite.rs8
-rw-r--r--src/event/handlers/stream/test/setup.rs4
-rw-r--r--src/event/handlers/stream/test/token.rs4
-rw-r--r--src/invite/app.rs50
-rw-r--r--src/invite/handlers/accept/test.rs28
-rw-r--r--src/invite/handlers/issue/mod.rs2
-rw-r--r--src/invite/handlers/issue/test.rs2
-rw-r--r--src/invite/repo.rs3
-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)32
-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)15
-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.rs47
-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.rs21
-rw-r--r--src/setup/handlers/setup/test.rs30
-rw-r--r--src/test/fixtures/cookie.rs14
-rw-r--r--src/test/fixtures/identity.rs15
-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/mod.rs1
-rw-r--r--src/test/verify/identity.rs33
-rw-r--r--src/test/verify/login.rs25
-rw-r--r--src/test/verify/mod.rs3
-rw-r--r--src/test/verify/token.rs34
-rw-r--r--src/token/app.rs126
-rw-r--r--src/token/extract/identity.rs20
-rw-r--r--src/token/mod.rs28
-rw-r--r--src/token/repo/auth.rs103
-rw-r--r--src/token/repo/mod.rs1
-rw-r--r--src/token/repo/token.rs97
-rw-r--r--src/user/app.rs14
-rw-r--r--src/user/create.rs28
-rw-r--r--src/user/handlers/password/test.rs81
-rw-r--r--src/user/history.rs27
-rw-r--r--src/user/id.rs17
-rw-r--r--src/user/mod.rs3
-rw-r--r--src/user/repo.rs108
-rw-r--r--ui/lib/session.svelte.js8
83 files changed, 1357 insertions, 908 deletions
diff --git a/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json b/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json
new file mode 100644
index 0000000..ca93083
--- /dev/null
+++ b/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json
@@ -0,0 +1,38 @@
+{
+ "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: login::Id\",\n issued_at as \"issued_at: DateTime\",\n last_used_at as \"last_used_at: DateTime\"\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "id: Id",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "login: login::Id",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "issued_at: DateTime",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "last_used_at: DateTime",
+ "ordinal": 3,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271"
+}
diff --git a/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json b/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json
new file mode 100644
index 0000000..37a6dd3
--- /dev/null
+++ b/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert\n into token (id, secret, login, issued_at, last_used_at)\n values ($1, $2, $3, $4, $5)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 5
+ },
+ "nullable": []
+ },
+ "hash": "077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8"
+}
diff --git a/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json b/.sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json
index 1be8e07..937b07e 100644
--- a/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json
+++ b/.sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
- "query": "\n delete\n from token\n where id = $1\n returning id as \"id: Id\"\n ",
+ "query": "\n delete\n from token\n where login = $1\n returning id as \"id: Id\"\n ",
"describe": {
"columns": [
{
@@ -16,5 +16,5 @@
false
]
},
- "hash": "b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f"
+ "hash": "0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d"
}
diff --git a/.sqlx/query-0be9484c8d277b08a925b7776b5d0e847cf52c68f24f5ea878b897944e70254c.json b/.sqlx/query-0be9484c8d277b08a925b7776b5d0e847cf52c68f24f5ea878b897944e70254c.json
deleted file mode 100644
index 182f996..0000000
--- a/.sqlx/query-0be9484c8d277b08a925b7776b5d0e847cf52c68f24f5ea878b897944e70254c.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n insert\n into token (id, secret, user, issued_at, last_used_at)\n values ($1, $2, $3, $4, $4)\n returning secret as \"secret!: Secret\"\n ",
- "describe": {
- "columns": [
- {
- "name": "secret!: Secret",
- "ordinal": 0,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 4
- },
- "nullable": [
- false
- ]
- },
- "hash": "0be9484c8d277b08a925b7776b5d0e847cf52c68f24f5ea878b897944e70254c"
-}
diff --git a/.sqlx/query-27c3ffb5c284cf2d1c7cee713f6354d6150088a052d1f261853f327a83e8dd75.json b/.sqlx/query-27c3ffb5c284cf2d1c7cee713f6354d6150088a052d1f261853f327a83e8dd75.json
deleted file mode 100644
index c02667e..0000000
--- a/.sqlx/query-27c3ffb5c284cf2d1c7cee713f6354d6150088a052d1f261853f327a83e8dd75.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n update user\n set password_hash = $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": "27c3ffb5c284cf2d1c7cee713f6354d6150088a052d1f261853f327a83e8dd75"
-}
diff --git a/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json b/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json
new file mode 100644
index 0000000..c9edfc9
--- /dev/null
+++ b/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete from token\n where id = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4"
+}
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-613199c25e16a52d85761e213750fe202abfa2af5347010dee59d3b8075eb19e.json b/.sqlx/query-613199c25e16a52d85761e213750fe202abfa2af5347010dee59d3b8075eb19e.json
deleted file mode 100644
index 1657efa..0000000
--- a/.sqlx/query-613199c25e16a52d85761e213750fe202abfa2af5347010dee59d3b8075eb19e.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n delete\n from token\n where user = $1\n returning id as \"id: Id\"\n ",
- "describe": {
- "columns": [
- {
- "name": "id: Id",
- "ordinal": 0,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 1
- },
- "nullable": [
- false
- ]
- },
- "hash": "613199c25e16a52d85761e213750fe202abfa2af5347010dee59d3b8075eb19e"
-}
diff --git a/.sqlx/query-684b4bef3e553b8ccfa20d8de3272e44b1c15ed75977ce24f25fd62042fb20fb.json b/.sqlx/query-684b4bef3e553b8ccfa20d8de3272e44b1c15ed75977ce24f25fd62042fb20fb.json
deleted file mode 100644
index d1c2732..0000000
--- a/.sqlx/query-684b4bef3e553b8ccfa20d8de3272e44b1c15ed75977ce24f25fd62042fb20fb.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"token: Id\",\n user as \"user: user::Id\"\n ",
- "describe": {
- "columns": [
- {
- "name": "token: Id",
- "ordinal": 0,
- "type_info": "Text"
- },
- {
- "name": "user: user::Id",
- "ordinal": 1,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 2
- },
- "nullable": [
- false,
- false
- ]
- },
- "hash": "684b4bef3e553b8ccfa20d8de3272e44b1c15ed75977ce24f25fd62042fb20fb"
-}
diff --git a/.sqlx/query-79495da0101ad12e7e983666b2ca84cbe31458ff73a94648c30b62f3130a5a8b.json b/.sqlx/query-79495da0101ad12e7e983666b2ca84cbe31458ff73a94648c30b62f3130a5a8b.json
deleted file mode 100644
index 8f9be21..0000000
--- a/.sqlx/query-79495da0101ad12e7e983666b2ca84cbe31458ff73a94648c30b62f3130a5a8b.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n select\n id as \"id: user::Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\",\n password_hash as \"password_hash: StoredHash\"\n from user\n where 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_hash: StoredHash",
- "ordinal": 5,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 1
- },
- "nullable": [
- false,
- false,
- false,
- false,
- false,
- false
- ]
- },
- "hash": "79495da0101ad12e7e983666b2ca84cbe31458ff73a94648c30b62f3130a5a8b"
-}
diff --git a/.sqlx/query-7c87864e6d077640e4f3ef4a354a47265238010f4c53d3c5982f4fd95ed1f6d0.json b/.sqlx/query-7c87864e6d077640e4f3ef4a354a47265238010f4c53d3c5982f4fd95ed1f6d0.json
new file mode 100644
index 0000000..012274a
--- /dev/null
+++ b/.sqlx/query-7c87864e6d077640e4f3ef4a354a47265238010f4c53d3c5982f4fd95ed1f6d0.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert into login (id, display_name, canonical_name, password)\n values ($1, $2, $3, $4)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 4
+ },
+ "nullable": []
+ },
+ "hash": "7c87864e6d077640e4f3ef4a354a47265238010f4c53d3c5982f4fd95ed1f6d0"
+}
diff --git a/.sqlx/query-9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2.json b/.sqlx/query-8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c.json
index 45177f3..cbe1cdf 100644
--- a/.sqlx/query-9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2.json
+++ b/.sqlx/query-8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c.json
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
- "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from user\n where created_sequence <= $1\n order by canonical_name\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_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\"\n from user\n join login using (id)\n where user.created_sequence > $1\n ",
"describe": {
"columns": [
{
@@ -40,5 +40,5 @@
false
]
},
- "hash": "9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2"
+ "hash": "8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c"
}
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-53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183.json b/.sqlx/query-963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477.json
index 31c14de..1025308 100644
--- a/.sqlx/query-53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183.json
+++ b/.sqlx/query-963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477.json
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
- "query": "\n select\n invite.id as \"invite_id: Id\",\n issuer.id as \"issuer_id: user::Id\",\n issuer.display_name as \"issuer_name: nfc::String\",\n invite.issued_at as \"invite_issued_at: DateTime\"\n from invite\n join user as issuer on (invite.issuer = issuer.id)\n where invite.id = $1\n ",
+ "query": "\n select\n invite.id as \"invite_id: Id\",\n issuer.id as \"issuer_id: user::Id\",\n issuer_login.display_name as \"issuer_name: nfc::String\",\n invite.issued_at as \"invite_issued_at: DateTime\"\n from invite\n join user as issuer on (invite.issuer = issuer.id)\n join login as issuer_login on (issuer.id = issuer_login.id)\n where invite.id = $1\n ",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
false
]
},
- "hash": "53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183"
+ "hash": "963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477"
}
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-afc6db503a3c49c18a9cb07cf0360789d46d94d9cd98887a3d9660d9b753d416.json b/.sqlx/query-afc6db503a3c49c18a9cb07cf0360789d46d94d9cd98887a3d9660d9b753d416.json
deleted file mode 100644
index b7e6c1b..0000000
--- a/.sqlx/query-afc6db503a3c49c18a9cb07cf0360789d46d94d9cd98887a3d9660d9b753d416.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n select\n id as \"id: user::Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\",\n password_hash as \"password_hash: StoredHash\"\n from user\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_hash: StoredHash",
- "ordinal": 5,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 1
- },
- "nullable": [
- false,
- false,
- false,
- false,
- false,
- false
- ]
- },
- "hash": "afc6db503a3c49c18a9cb07cf0360789d46d94d9cd98887a3d9660d9b753d416"
-}
diff --git a/.sqlx/query-9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281.json b/.sqlx/query-be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa.json
index 0926b67..e56faa9 100644
--- a/.sqlx/query-9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281.json
+++ b/.sqlx/query-be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa.json
@@ -1,10 +1,10 @@
{
"db_name": "SQLite",
- "query": "\n select\n id as \"id: user::Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from user\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_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\"\n from user\n join login using (id)\n where user.created_sequence <= $1\n order by canonical_name\n ",
"describe": {
"columns": [
{
- "name": "id: user::Id",
+ "name": "id: Id",
"ordinal": 0,
"type_info": "Text"
},
@@ -40,5 +40,5 @@
false
]
},
- "hash": "9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281"
+ "hash": "be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa"
}
diff --git a/.sqlx/query-9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454.json b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json
index beacb24..789151d 100644
--- a/.sqlx/query-9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454.json
+++ b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
- "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from user\n where created_sequence > $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": [
{
@@ -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": "9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454"
+ "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-f9a6a39c45c3b039f139da0475c25112ffb2b26584ada60c324fc2f945c3d2fa.json b/.sqlx/query-f9a6a39c45c3b039f139da0475c25112ffb2b26584ada60c324fc2f945c3d2fa.json
new file mode 100644
index 0000000..6050e22
--- /dev/null
+++ b/.sqlx/query-f9a6a39c45c3b039f139da0475c25112ffb2b26584ada60c324fc2f945c3d2fa.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert into user (id, created_sequence, created_at)\n values ($1, $2, $3)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 3
+ },
+ "nullable": []
+ },
+ "hash": "f9a6a39c45c3b039f139da0475c25112ffb2b26584ada60c324fc2f945c3d2fa"
+}
diff --git a/.sqlx/query-f9abb172f96bff3a5fb4ad29f3a52be8d3134fc68e37c6074c69970604ae3844.json b/.sqlx/query-f9abb172f96bff3a5fb4ad29f3a52be8d3134fc68e37c6074c69970604ae3844.json
deleted file mode 100644
index 9d1bc77..0000000
--- a/.sqlx/query-f9abb172f96bff3a5fb4ad29f3a52be8d3134fc68e37c6074c69970604ae3844.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n insert\n into user (id, display_name, canonical_name, password_hash, created_sequence, created_at)\n values ($1, $2, $3, $4, $5, $6)\n ",
- "describe": {
- "columns": [],
- "parameters": {
- "Right": 6
- },
- "nullable": []
- },
- "hash": "f9abb172f96bff3a5fb4ad29f3a52be8d3134fc68e37c6074c69970604ae3844"
-}
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/migrations/20250822213537_split_user.sql b/migrations/20250822213537_split_user.sql
new file mode 100644
index 0000000..1dbbad6
--- /dev/null
+++ b/migrations/20250822213537_split_user.sql
@@ -0,0 +1,195 @@
+-- First, migrate the table we're actually interested in - `user`. This splits it into two parts:
+--
+-- * login, which is concerned with authentication data (and is non-synchronized,
+-- though at the DB that doesn't matter much), and
+-- * user, which is concerned with chat data (and is synchronized, ditto).
+
+alter table user
+ rename to old_user;
+
+create table login (
+ id text
+ not null
+ primary key,
+ display_name text
+ not null,
+ canonical_name text
+ not null
+ unique,
+ password text
+ not null
+);
+
+insert into
+ login (id, display_name, canonical_name, password)
+select
+ id,
+ display_name,
+ canonical_name,
+ password_hash as password
+from
+ old_user;
+
+create table user (
+ id text
+ not null
+ primary key
+ references login (id),
+ created_at text
+ not null,
+ created_sequence bigint
+ not null
+ unique
+);
+
+insert into
+ user (id, created_at, created_sequence)
+select
+ id,
+ created_at,
+ created_sequence
+from
+ old_user;
+
+-- Now, recreate the entire rest of the owl. Everything that referenced the original `user` table _except tokens_
+-- references the user table. Tokens, which are authentication data, reference the login (authn) table.
+
+alter table token
+ rename to old_token;
+
+create table token (
+ id text
+ not null
+ primary key,
+ secret text
+ not null
+ unique,
+ login text
+ not null
+ references login (id),
+ issued_at text
+ not null,
+ last_used_at text
+ not null
+);
+
+insert into
+ token (id, secret, login, issued_at, last_used_at)
+select
+ id,
+ secret,
+ user as login,
+ issued_at,
+ last_used_at
+from
+ old_token;
+
+alter table invite
+ rename to old_invite;
+
+create table invite (
+ id text
+ primary key
+ not null,
+ issuer text
+ not null
+ references user (id),
+ issued_at text
+ not null
+);
+
+insert into
+ invite (id, issuer, issued_at)
+select
+ id,
+ issuer,
+ issued_at
+from
+ old_invite;
+
+alter table message
+ rename to old_message;
+
+create table message (
+ id text
+ not null
+ primary key,
+ conversation text
+ not null
+ references conversation (id),
+ sender text
+ not null
+ references user (id),
+ body text
+ null,
+ sent_at text
+ not null,
+ sent_sequence bigint
+ unique
+ not null,
+ last_sequence bigint
+ not null
+);
+
+insert into
+ message (id, conversation, sender, body, sent_at, sent_sequence, last_sequence)
+select
+ id,
+ conversation,
+ sender,
+ body,
+ sent_at,
+ sent_sequence,
+ last_sequence
+from
+ old_message;
+
+alter table message_deleted
+ rename to old_message_deleted;
+
+create table message_deleted (
+ id text
+ not null
+ primary key
+ references message (id),
+ deleted_at text
+ not null,
+ deleted_sequence bigint
+ unique
+ not null
+);
+
+insert into
+ message_deleted (id, deleted_at, deleted_sequence)
+select
+ id,
+ deleted_at,
+ deleted_sequence
+from
+ message_deleted;
+
+drop table old_message_deleted;
+drop table old_message;
+drop table old_invite;
+drop table old_token;
+drop table old_user;
+
+create index token_issued_at
+ on token (issued_at);
+create index token_last_used_at
+ on token (last_used_at);
+create index token_login
+ on token (login);
+
+create index invite_issued_at
+ on invite (issued_at);
+
+create index message_sent_at
+ on message (sent_at);
+create index message_conversation
+ on message (conversation);
+create index message_last_sequence
+ on message (last_sequence);
+
+create index message_deleted_deleted_at
+ on message_deleted (deleted_at);
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/mod.rs b/src/event/handlers/stream/mod.rs
index d0d3f08..63bfff3 100644
--- a/src/event/handlers/stream/mod.rs
+++ b/src/event/handlers/stream/mod.rs
@@ -27,7 +27,7 @@ pub async fn handler(
let resume_at = last_event_id.map_or(query.resume_point, LastEventId::into_inner);
let stream = app.events().subscribe(resume_at).await?;
- let stream = app.tokens().limit_stream(identity.token, stream).await?;
+ let stream = app.tokens().limit_stream(&identity.token, stream).await?;
Ok(Response(stream))
}
diff --git a/src/event/handlers/stream/test/invite.rs b/src/event/handlers/stream/test/invite.rs
index f2d6950..d4ac82d 100644
--- a/src/event/handlers/stream/test/invite.rs
+++ b/src/event/handlers/stream/test/invite.rs
@@ -33,7 +33,7 @@ async fn accepting_invite() {
.accept(&invite.id, &name, &password, &fixtures::now())
.await
.expect("accepting an invite succeeds");
- let (_, joiner) = app
+ let joiner = app
.tokens()
.validate(&joiner, &fixtures::now())
.await
@@ -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))
+ .filter(|event| future::ready(event.user.id == joiner.login.id))
.next()
.expect_some("a login created event is sent")
.await;
@@ -67,7 +67,7 @@ async fn previously_accepted_invite() {
.accept(&invite.id, &name, &password, &fixtures::now())
.await
.expect("accepting an invite succeeds");
- let (_, joiner) = app
+ let joiner = app
.tokens()
.validate(&joiner, &fixtures::now())
.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))
+ .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 297162e..3f01ccb 100644
--- a/src/event/handlers/stream/test/setup.rs
+++ b/src/event/handlers/stream/test/setup.rs
@@ -22,7 +22,7 @@ async fn previously_completed() {
.initial(&name, &password, &fixtures::now())
.await
.expect("initial setup in an empty app succeeds");
- let (_, owner) = app
+ let owner = app
.tokens()
.validate(&secret, &fixtures::now())
.await
@@ -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))
+ .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 1c85562..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, repo::Provider as _},
+ 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,7 +83,9 @@ impl<'a> Invites<'a> {
.store(&mut tx)
.await
.duplicate(|| AcceptError::DuplicateLogin(name.clone()))?;
- let secret = tx.tokens().issue(stored.user(), accepted_at).await?;
+ let login = stored.login();
+ let (token, secret) = Token::generate(login, accepted_at);
+ tx.tokens().create(&token, &secret).await?;
tx.commit().await?;
stored.publish(self.events);
@@ -92,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 adc7aa4..283ec76 100644
--- a/src/invite/handlers/accept/test.rs
+++ b/src/invite/handlers/accept/test.rs
@@ -1,6 +1,11 @@
use axum::extract::{Json, Path, State};
-use crate::{empty::Empty, invite::app::AcceptError, name::Name, test::fixtures};
+use crate::{
+ empty::Empty,
+ invite::app::AcceptError,
+ name::Name,
+ test::{fixtures, verify},
+};
#[tokio::test]
async fn valid_invite() {
@@ -34,29 +39,16 @@ async fn valid_invite() {
// Verify that the issued token is valid
- let secret = identity
- .secret()
- .expect("newly-issued identity has a token secret");
- let (_, login) = app
- .tokens()
- .validate(&secret, &fixtures::now())
- .await
- .expect("newly-issued identity cookie is valid");
- assert_eq!(name, login.name);
+ verify::identity::valid_for_name(&app, &identity, &name).await;
// 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");
- let (_, login) = app
- .tokens()
- .validate(&secret, &fixtures::now())
- .await
- .expect("validating a newly-issued token secret succeeds");
- assert_eq!(name, login.name);
+ verify::token::valid_for_name(&app, &secret, &name).await;
}
#[tokio::test]
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/invite/repo.rs b/src/invite/repo.rs
index 7cfa18e..934b0ce 100644
--- a/src/invite/repo.rs
+++ b/src/invite/repo.rs
@@ -71,10 +71,11 @@ impl Invites<'_> {
select
invite.id as "invite_id: Id",
issuer.id as "issuer_id: user::Id",
- issuer.display_name as "issuer_name: nfc::String",
+ issuer_login.display_name as "issuer_name: nfc::String",
invite.issued_at as "invite_issued_at: DateTime"
from invite
join user as issuer on (invite.issuer = issuer.id)
+ join login as issuer_login on (issuer.id = issuer_login.id)
where invite.id = $1
"#,
invite,
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 bdd1957..f3911d0 100644
--- a/src/user/handlers/login/test.rs
+++ b/src/login/handlers/login/test.rs
@@ -1,6 +1,10 @@
use axum::extract::{Json, State};
-use crate::{empty::Empty, test::fixtures, token::app};
+use crate::{
+ empty::Empty,
+ login::app::LoginError,
+ test::{fixtures, verify},
+};
#[tokio::test]
async fn correct_credentials() {
@@ -29,15 +33,7 @@ async fn correct_credentials() {
.expect("logged in with valid credentials issues an identity cookie");
// Verify the semantics
-
- let validated_at = fixtures::now();
- let (_, validated_login) = app
- .tokens()
- .validate(&secret, &validated_at)
- .await
- .expect("identity secret is valid");
-
- assert_eq!(name, validated_login.name);
+ verify::token::valid_for_name(&app, &secret, &name).await;
}
#[tokio::test]
@@ -62,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]
@@ -87,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]
@@ -109,18 +105,10 @@ 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");
- let verified_at = fixtures::now();
- let error = app
- .tokens()
- .validate(&secret, &verified_at)
- .await
- .expect_err("validating an expired token");
-
- assert!(matches!(error, app::ValidateError::InvalidToken));
+ verify::token::invalid(&app, &secret).await;
}
diff --git a/src/user/handlers/logout/mod.rs b/src/login/handlers/logout/mod.rs
index 4450e4c..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 (token, _) = app.tokens().validate(&secret, &now).await?;
- app.tokens().logout(&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 7151ddf..e7b7dd4 100644
--- a/src/user/handlers/logout/test.rs
+++ b/src/login/handlers/logout/test.rs
@@ -1,6 +1,10 @@
use axum::extract::{Json, State};
-use crate::{empty::Empty, test::fixtures, token::app};
+use crate::{
+ empty::Empty,
+ test::{fixtures, verify},
+ token::app,
+};
#[tokio::test]
async fn successful() {
@@ -10,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
@@ -24,16 +27,10 @@ async fn successful() {
.expect("logged out with a valid token");
// Verify the return value's basic structure
-
assert!(response_identity.secret().is_none());
// Verify the semantics
- let error = app
- .tokens()
- .validate(&secret, &now)
- .await
- .expect_err("secret is invalid");
- assert!(matches!(error, app::ValidateError::InvalidToken));
+ 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/login/handlers/password/test.rs b/src/login/handlers/password/test.rs
new file mode 100644
index 0000000..ba2f28f
--- /dev/null
+++ b/src/login/handlers/password/test.rs
@@ -0,0 +1,47 @@
+use axum::extract::{Json, State};
+
+use crate::{
+ empty::Empty,
+ test::{fixtures, verify},
+};
+
+#[tokio::test]
+async fn password_change() {
+ // Set up the environment
+ let app = fixtures::scratch_app().await;
+ let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await;
+ let cookie = fixtures::cookie::logged_in(&app, &creds, &fixtures::now()).await;
+ let identity = fixtures::identity::from_cookie(&app, &cookie, &fixtures::now()).await;
+
+ // Call the endpoint
+ let (name, password) = creds;
+ let to = fixtures::user::propose_password();
+ let request = super::Request {
+ password: password.clone(),
+ to: to.clone(),
+ };
+ let (new_cookie, Empty) = super::handler(
+ State(app.clone()),
+ fixtures::now(),
+ identity.clone(),
+ cookie.clone(),
+ Json(request),
+ )
+ .await
+ .expect("changing passwords succeeds");
+
+ // Verify that we have a new session
+ assert_ne!(cookie.secret(), new_cookie.secret());
+
+ // Verify that we're still ourselves
+ 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;
+
+ // Verify that our original password is no longer valid
+ verify::login::invalid_login(&app, &name, &password).await;
+
+ // Verify that our new password is valid
+ verify::login::valid_login(&app, &name, &to).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 f6b97e5..b848afb 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 1856519..2a8ec30 100644
--- a/src/setup/app.rs
+++ b/src/setup/app.rs
@@ -6,7 +6,7 @@ use crate::{
event::Broadcaster,
name::Name,
password::Password,
- token::{Secret, repo::Provider as _},
+ token::{Secret, Token, repo::Provider as _},
user::create::{self, Create},
};
@@ -31,17 +31,20 @@ impl<'a> Setup<'a> {
let validated = create.validate()?;
let mut tx = self.db.begin().await?;
- let stored = if tx.setup().completed().await? {
- Err(Error::SetupCompleted)?
+ if tx.setup().completed().await? {
+ Err(Error::SetupCompleted)
} else {
- validated.store(&mut tx).await?
- };
- let secret = tx.tokens().issue(stored.user(), created_at).await?;
- tx.commit().await?;
+ let stored = validated.store(&mut tx).await?;
+ let login = stored.login();
+
+ let (token, secret) = Token::generate(login, created_at);
+ tx.tokens().create(&token, &secret).await?;
+ tx.commit().await?;
- stored.publish(self.events);
+ stored.publish(self.events);
- Ok(secret)
+ Ok(secret)
+ }
}
pub async fn completed(&self) -> Result<bool, sqlx::Error> {
diff --git a/src/setup/handlers/setup/test.rs b/src/setup/handlers/setup/test.rs
index 4a37690..283fe8b 100644
--- a/src/setup/handlers/setup/test.rs
+++ b/src/setup/handlers/setup/test.rs
@@ -1,6 +1,10 @@
use axum::extract::{Json, State};
-use crate::{empty::Empty, setup::app, test::fixtures};
+use crate::{
+ empty::Empty,
+ setup::app,
+ test::{fixtures, verify},
+};
#[tokio::test]
async fn fresh_instance() {
@@ -21,30 +25,10 @@ async fn fresh_instance() {
.expect("setup in a fresh app succeeds");
// Verify that the issued token is valid
-
- let secret = identity
- .secret()
- .expect("newly-issued identity has a token secret");
- let (_, login) = app
- .tokens()
- .validate(&secret, &fixtures::now())
- .await
- .expect("newly-issued identity cookie is valid");
- assert_eq!(name, login.name);
+ verify::identity::valid_for_name(&app, &identity, &name).await;
// Verify that the given credentials can log in
-
- let secret = app
- .tokens()
- .login(&name, &password, &fixtures::now())
- .await
- .expect("credentials given on signup are valid");
- let (_, login) = app
- .tokens()
- .validate(&secret, &fixtures::now())
- .await
- .expect("validating a newly-issued token secret succeeds");
- assert_eq!(name, login.name);
+ verify::login::valid_login(&app, &name, &password).await;
}
#[tokio::test]
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 84e1cf6..93e4a38 100644
--- a/src/test/fixtures/identity.rs
+++ b/src/test/fixtures/identity.rs
@@ -5,7 +5,7 @@ use crate::{
password::Password,
test::fixtures,
token::{
- self,
+ Token,
extract::{Identity, IdentityCookie},
},
};
@@ -21,13 +21,10 @@ pub async fn from_cookie(
validated_at: &RequestedAt,
) -> Identity {
let secret = cookie.secret().expect("identity token has a secret");
- let (token, user) = app
- .tokens()
+ app.tokens()
.validate(&secret, validated_at)
.await
- .expect("always validates newly-issued secret");
-
- Identity { token, user }
+ .expect("always validates newly-issued secret")
}
pub async fn logged_in(
@@ -40,8 +37,8 @@ pub async fn logged_in(
}
pub fn fictitious() -> Identity {
- let token = token::Id::generate();
- let user = fixtures::user::fictitious();
+ 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/mod.rs b/src/test/mod.rs
index d066349..ebbbfef 100644
--- a/src/test/mod.rs
+++ b/src/test/mod.rs
@@ -1 +1,2 @@
pub mod fixtures;
+pub mod verify;
diff --git a/src/test/verify/identity.rs b/src/test/verify/identity.rs
new file mode 100644
index 0000000..8e2d36e
--- /dev/null
+++ b/src/test/verify/identity.rs
@@ -0,0 +1,33 @@
+use crate::{
+ app::App,
+ login::Login,
+ name::Name,
+ test::{fixtures, verify},
+ token::{app::ValidateError, extract::IdentityCookie},
+};
+
+pub async fn valid_for_name(app: &App, identity: &IdentityCookie, name: &Name) {
+ let secret = identity
+ .secret()
+ .expect("identity cookie must be set to be valid");
+ verify::token::valid_for_name(app, &secret, name).await;
+}
+
+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_login(app, &secret, login).await;
+}
+
+pub async fn invalid(app: &App, identity: &IdentityCookie) {
+ let secret = identity
+ .secret()
+ .expect("identity cookie must be set to be invalid");
+ let validate_err = app
+ .tokens()
+ .validate(&secret, &fixtures::now())
+ .await
+ .expect_err("identity cookie secret must be invalid");
+ assert!(matches!(validate_err, ValidateError::InvalidToken));
+}
diff --git a/src/test/verify/login.rs b/src/test/verify/login.rs
new file mode 100644
index 0000000..ae2e91e
--- /dev/null
+++ b/src/test/verify/login.rs
@@ -0,0 +1,25 @@
+use crate::{
+ app::App,
+ login::app::LoginError,
+ name::Name,
+ password::Password,
+ test::{fixtures, verify},
+};
+
+pub async fn valid_login(app: &App, name: &Name, password: &Password) {
+ let secret = app
+ .logins()
+ .with_password(name, password, &fixtures::now())
+ .await
+ .expect("login credentials expected to be valid");
+ verify::token::valid_for_name(&app, &secret, &name).await;
+}
+
+pub async fn invalid_login(app: &App, name: &Name, password: &Password) {
+ let error = app
+ .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/mod.rs b/src/test/verify/mod.rs
new file mode 100644
index 0000000..f809c90
--- /dev/null
+++ b/src/test/verify/mod.rs
@@ -0,0 +1,3 @@
+pub mod identity;
+pub mod login;
+pub mod token;
diff --git a/src/test/verify/token.rs b/src/test/verify/token.rs
new file mode 100644
index 0000000..adc4397
--- /dev/null
+++ b/src/test/verify/token.rs
@@ -0,0 +1,34 @@
+use crate::{
+ app::App,
+ login::Login,
+ name::Name,
+ test::fixtures,
+ token::{Secret, app},
+};
+
+pub async fn valid_for_name(app: &App, secret: &Secret, name: &Name) {
+ let identity = app
+ .tokens()
+ .validate(secret, &fixtures::now())
+ .await
+ .expect("provided secret is valid");
+ assert_eq!(name, &identity.login.name);
+}
+
+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!(login, &identity.login);
+}
+
+pub async fn invalid(app: &App, secret: &Secret) {
+ let error = app
+ .tokens()
+ .validate(secret, &fixtures::now())
+ .await
+ .expect_err("provided secret is invalid");
+ assert!(matches!(error, app::ValidateError::InvalidToken));
+}
diff --git a/src/token/app.rs b/src/token/app.rs
index 56c0e21..fb5d712 100644
--- a/src/token/app.rs
+++ b/src/token/app.rs
@@ -6,16 +6,11 @@ use futures::{
use sqlx::sqlite::SqlitePool;
use super::{
- Broadcaster, Event as TokenEvent, Id, Secret,
- repo::{self, Provider as _, auth::Provider as _},
-};
-use crate::{
- clock::DateTime,
- db::NotFound as _,
- name::{self, Name},
- password::Password,
- user::{User, repo::Provider as _},
+ Broadcaster, Event as TokenEvent, Secret, Token,
+ extract::Identity,
+ repo::{self, Provider as _},
};
+use crate::{clock::DateTime, db::NotFound as _, name};
pub struct Tokens<'a> {
db: &'a SqlitePool,
@@ -27,106 +22,33 @@ 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?;
-
- user.as_snapshot().ok_or(LoginError::Rejected)?;
-
- if stored_hash.verify(password)? {
- let mut tx = self.db.begin().await?;
- let secret = tx.tokens().issue(&user, login_at).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);
- }
-
- user.as_snapshot().ok_or(LoginError::Rejected)?;
- let to_hash = to.hash()?;
-
- let mut tx = self.db.begin().await?;
- let tokens = tx.tokens().revoke_all(&user).await?;
- tx.users().set_password(&user, &to_hash).await?;
- let secret = tx.tokens().issue(&user, changed_at).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<(Id, User), ValidateError> {
+ ) -> 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((token, user))
+ Ok(Identity { token, login })
}
pub async fn limit_stream<S, E>(
&self,
- token: Id,
+ token: &Token,
events: S,
) -> Result<impl Stream<Item = E> + std::fmt::Debug + use<S, E>, ValidateError>
where
S: Stream<Item = E> + std::fmt::Debug,
E: std::fmt::Debug,
{
+ let token = token.id.clone();
+
// Subscribe, first.
let token_events = self.token_events.subscribe();
@@ -187,46 +109,22 @@ impl<'a> Tokens<'a> {
Ok(())
}
- pub async fn logout(&self, token: &Id) -> Result<(), ValidateError> {
+ pub async fn logout(&self, token: &Token) -> Result<(), ValidateError> {
let mut tx = self.db.begin().await?;
tx.tokens().revoke(token).await?;
tx.commit().await?;
self.token_events
- .broadcast(TokenEvent::Revoked(token.clone()));
+ .broadcast(TokenEvent::Revoked(token.id.clone()));
Ok(())
}
}
#[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 d1c0334..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},
- token::{self, app::ValidateError},
- user::User,
+ login::Login,
+ token::{Token, app::ValidateError},
};
#[derive(Clone, Debug)]
pub struct Identity {
- pub token: token::Id,
- pub user: User,
+ pub token: Token,
+ pub login: Login,
}
impl FromRequestParts<App> for Identity {
@@ -30,11 +30,13 @@ impl FromRequestParts<App> for Identity {
let secret = cookie.secret().ok_or(LoginError::Unauthorized)?;
let app = State::<App>::from_request_parts(parts, state).await?;
- match app.tokens().validate(&secret, &used_at).await {
- Ok((token, user)) => Ok(Identity { token, user }),
- Err(ValidateError::InvalidToken) => Err(LoginError::Unauthorized),
- Err(other) => Err(other.into()),
- }
+ app.tokens()
+ .validate(&secret, &used_at)
+ .await
+ .map_err(|err| match err {
+ ValidateError::InvalidToken => LoginError::Unauthorized,
+ other => other.into(),
+ })
}
}
diff --git a/src/token/mod.rs b/src/token/mod.rs
index 33403ef..b2dd6f1 100644
--- a/src/token/mod.rs
+++ b/src/token/mod.rs
@@ -6,4 +6,32 @@ mod id;
pub mod repo;
mod secret;
+use uuid::Uuid;
+
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 login: login::Id,
+ pub issued_at: DateTime,
+ pub last_used_at: DateTime,
+}
+
+impl Token {
+ pub fn generate(login: &Login, issued_at: &DateTime) -> (Self, Secret) {
+ let id = Id::generate();
+ let secret = Uuid::new_v4().to_string().into();
+
+ (
+ Self {
+ id,
+ login: login.id.clone(),
+ issued_at: *issued_at,
+ last_used_at: *issued_at,
+ },
+ secret,
+ )
+ }
+}
diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs
deleted file mode 100644
index 600855d..0000000
--- a/src/token/repo/auth.rs
+++ /dev/null
@@ -1,103 +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",
- display_name as "display_name: String",
- canonical_name as "canonical_name: String",
- created_sequence as "created_sequence: Sequence",
- created_at as "created_at: DateTime",
- password_hash as "password_hash: StoredHash"
- from user
- where 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_hash))
- }
-
- pub async fn for_user(&mut self, user: &User) -> Result<(History, StoredHash), LoadError> {
- let row = sqlx::query!(
- r#"
- select
- id as "id: user::Id",
- display_name as "display_name: String",
- canonical_name as "canonical_name: String",
- created_sequence as "created_sequence: Sequence",
- created_at as "created_at: DateTime",
- password_hash as "password_hash: StoredHash"
- from user
- 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_hash))
- }
-}
-
-#[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 7ac4ac5..52a3987 100644
--- a/src/token/repo/token.rs
+++ b/src/token/repo/token.rs
@@ -1,13 +1,11 @@
use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite};
-use uuid::Uuid;
use crate::{
clock::DateTime,
db::NotFound,
- event::{Instant, Sequence},
+ login::{self, Login},
name::{self, Name},
- token::{Id, Secret},
- user::{self, History, User},
+ token::{Id, Secret, Token},
};
pub trait Provider {
@@ -23,33 +21,23 @@ impl Provider for Transaction<'_, Sqlite> {
pub struct Tokens<'t>(&'t mut SqliteConnection);
impl Tokens<'_> {
- // Issue a new token for an existing user. The issued_at timestamp will
- // determine the token's initial expiry deadline.
- pub async fn issue(
- &mut self,
- user: &History,
- issued_at: &DateTime,
- ) -> Result<Secret, sqlx::Error> {
- let id = Id::generate();
- let secret = Uuid::new_v4().to_string();
- let user = user.id();
-
- let secret = sqlx::query_scalar!(
+ pub async fn create(&mut self, token: &Token, secret: &Secret) -> Result<(), sqlx::Error> {
+ sqlx::query!(
r#"
insert
- into token (id, secret, user, issued_at, last_used_at)
- values ($1, $2, $3, $4, $4)
- returning secret as "secret!: Secret"
+ into token (id, secret, login, issued_at, last_used_at)
+ values ($1, $2, $3, $4, $5)
"#,
- id,
+ token.id,
secret,
- user,
- issued_at,
+ token.login,
+ token.issued_at,
+ token.last_used_at,
)
- .fetch_one(&mut *self.0)
+ .execute(&mut *self.0)
.await?;
- Ok(secret)
+ Ok(())
}
pub async fn require(&mut self, token: &Id) -> Result<(), sqlx::Error> {
@@ -67,34 +55,30 @@ impl Tokens<'_> {
Ok(())
}
- // Revoke a token by its secret.
- pub async fn revoke(&mut self, token: &Id) -> Result<(), sqlx::Error> {
- sqlx::query_scalar!(
+ pub async fn revoke(&mut self, token: &Token) -> Result<(), sqlx::Error> {
+ sqlx::query!(
r#"
- delete
- from token
+ delete from token
where id = $1
- returning id as "id: Id"
"#,
- token,
+ token.id,
)
- .fetch_one(&mut *self.0)
+ .execute(&mut *self.0)
.await?;
Ok(())
}
// 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
from token
- where user = $1
+ where login = $1
returning id as "id: Id"
"#,
- user,
+ login.id,
)
.fetch_all(&mut *self.0)
.await?;
@@ -120,54 +104,53 @@ 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<(Id, 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
// queries is fine, but it feels untidy.
- let (token, user) = sqlx::query!(
+ let token = sqlx::query!(
r#"
update token
set last_used_at = $1
where secret = $2
returning
- id as "token: Id",
- user as "user: user::Id"
+ id as "id: Id",
+ login as "login: login::Id",
+ issued_at as "issued_at: DateTime",
+ last_used_at as "last_used_at: DateTime"
"#,
used_at,
secret,
)
- .map(|row| (row.token, row.user))
+ .map(|row| Token {
+ id: row.id,
+ login: row.login,
+ issued_at: row.issued_at,
+ last_used_at: row.last_used_at,
+ })
.fetch_one(&mut *self.0)
.await?;
let user = sqlx::query!(
r#"
select
- id as "id: user::Id",
- display_name as "display_name: String",
- canonical_name as "canonical_name: String",
- created_sequence as "created_sequence: Sequence",
- created_at as "created_at: DateTime"
- from user
+ id as "id: login::Id",
+ display_name,
+ canonical_name
+ from login
where id = $1
"#,
- 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/handlers/password/test.rs b/src/user/handlers/password/test.rs
deleted file mode 100644
index ffa12f3..0000000
--- a/src/user/handlers/password/test.rs
+++ /dev/null
@@ -1,81 +0,0 @@
-use axum::extract::{Json, State};
-
-use crate::{
- empty::Empty,
- test::fixtures,
- token::app::{LoginError, ValidateError},
-};
-
-#[tokio::test]
-async fn password_change() {
- // Set up the environment
- let app = fixtures::scratch_app().await;
- let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await;
- let cookie = fixtures::cookie::logged_in(&app, &creds, &fixtures::now()).await;
- let identity = fixtures::identity::from_cookie(&app, &cookie, &fixtures::now()).await;
-
- // Call the endpoint
- let (name, password) = creds;
- let to = fixtures::user::propose_password();
- let request = super::Request {
- password: password.clone(),
- to: to.clone(),
- };
- let (new_cookie, Empty) = super::handler(
- State(app.clone()),
- fixtures::now(),
- identity.clone(),
- cookie.clone(),
- Json(request),
- )
- .await
- .expect("changing passwords succeeds");
-
- // Verify that we have a new session
- assert_ne!(cookie.secret(), new_cookie.secret());
-
- // Verify that we're still ourselves
- let new_secret = new_cookie
- .secret()
- .expect("we should have a secret after changing our password");
- let (_, login) = app
- .tokens()
- .validate(&new_secret, &fixtures::now())
- .await
- .expect("the newly-issued secret should be valid");
- assert_eq!(identity.user, login);
-
- // Verify that our original token is no longer valid
- let validate_err = app
- .tokens()
- .validate(
- &cookie
- .secret()
- .expect("original identity cookie has a secret"),
- &fixtures::now(),
- )
- .await
- .expect_err("validating the original identity secret should fail");
- assert!(matches!(validate_err, ValidateError::InvalidToken));
-
- // Verify that our original password is no longer valid
- let login_err = app
- .tokens()
- .login(&name, &password, &fixtures::now())
- .await
- .expect_err("logging in with the original password should fail");
- assert!(matches!(login_err, LoginError::Rejected));
-
- // Verify that our new password is valid
- let secret = app
- .tokens()
- .login(&name, &to, &fixtures::now())
- .await
- .expect("logging in with the new password should succeed");
- let (_, login) = app
- .tokens()
- .validate(&secret, &fixtures::now())
- .await
- .expect("validating a newly-issued token secret succeeds");
- assert_eq!(identity.user, login);
-}
diff --git a/src/user/history.rs b/src/user/history.rs
index 4f99130..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,22 +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.")
- #[cfg(test)]
- 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 0f67e9a..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,62 +25,58 @@ pub struct Users<'t>(&'t mut SqliteConnection);
impl Users<'_> {
pub async fn create(
&mut self,
- name: &Name,
- password_hash: &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 user (id, display_name, canonical_name, password_hash, created_sequence, created_at)
- values ($1, $2, $3, $4, $5, $6)
+ insert into user (id, created_sequence, created_at)
+ values ($1, $2, $3)
"#,
- id,
- display_name,
- canonical_name,
- password_hash,
+ 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 user
- set password_hash = $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> {
@@ -87,13 +84,14 @@ impl Users<'_> {
r#"
select
id as "id: Id",
- display_name as "display_name: String",
- canonical_name as "canonical_name: String",
- created_sequence as "created_sequence: Sequence",
- created_at as "created_at: DateTime"
+ 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
- where created_sequence <= $1
- order by canonical_name
+ join login using (id)
+ where user.created_sequence <= $1
+ order by canonical_name
"#,
resume_at,
)
@@ -119,12 +117,13 @@ impl Users<'_> {
r#"
select
id as "id: Id",
- display_name as "display_name: String",
- canonical_name as "canonical_name: String",
- created_sequence as "created_sequence: Sequence",
- created_at as "created_at: DateTime"
+ 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
- where created_sequence > $1
+ join login using (id)
+ where user.created_sequence > $1
"#,
resume_at,
)
@@ -152,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,