Compare commits

...
130 Commits
Author SHA1 Message Date
nelind e85394c314 build: bump version to 0.6.3 2026-05-27 19:28:10 +02:00
isabelandTangled 411c6be108 feat(nix): validate config at build time 2026-05-27 20:14:06 +03:00
isabelandTangled 28aa7ab7fc feat!(tranquil-config): error on unknown keys
an attempt to make it so that fixes like
fc6063dba8 never have to occur again
2026-05-27 20:13:58 +03:00
nelindandTangled 348b69d76b feat(nix): ensure that tranquil-store path is always writable by the service user just like file blob storage 2026-05-27 20:13:07 +03:00
nelindandTangled 4b23ca2c36 fix(docs): some wording on nix config and CI stuffs 2026-05-27 20:13:07 +03:00
LewisandTangled a7052e878c feat: tranquil's own TLS handling
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-27 13:54:33 +03:00
nelind fc6063dba8 fix(nix): override the right frontend package setting in the flake nixos module 2026-05-27 01:04:31 +02:00
LewisandTangled 44cb016762 fix(docs): install docs better
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-26 09:49:59 +03:00
LewisandTangled e6eee18ace fix(docs): migration oauth update
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-25 19:02:13 +03:00
Lewis 96c8375706 fix(test): oauth token eviction should be agnostic
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-25 16:17:10 +03:00
Lewis 86c5995568 fix(config): signal gate is useless since needs admin work to activate anyway
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-24 22:50:14 +03:00
LewisandTangled 036c317fd6 fix(migrate): oauth refreshing, embedded db first invite code
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-24 19:28:57 +03:00
LewisandTangled 4d2c7d4723 feat(auth): verification-gate override, inbound-migration bypass, store deleter improvement
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-23 23:02:43 +03:00
isabelandTangled f24a9f8bc0 build(nix): frontend pin pnpm
some future protections against this acursed toolchain breaking and
people who do .follows on their nixpkgs inputs
2026-05-22 23:24:45 +03:00
Lewis f6ef6ecbd9 feat(docs): first version of 'when PRing' section of contrib
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-21 22:46:59 +03:00
karitham.devandTangled a2567bdb1a fix(locales): french handle should be "identifier" not "username"
My bad translation on that earlier, made it consistent with the rest of the file and usage!
2026-05-21 10:39:29 +03:00
Lewis 56120d252d fix(locale): a little more drift
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-21 09:14:06 +03:00
TrezyandTangled 210b0f463c feat: add About page
Signed-off-by: Trezy <tre@trezy.com>
2026-05-21 09:06:42 +03:00
LewisandTangled c80f504dc0 fix(locale): a couple of drift keys missing in other langs
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-21 09:06:10 +03:00
karitham.devandTangled a3bd7c59ad feat(locales): add french locale, fix missing error keys, remove dead keys
Adds french locale registration and translation file. Fixes 4 missing error keys that were referenced in code but absent from all locale files. Removes 2 unreferenced keys from `register.validation` that duplicated `registerPasskey.errors`.

I *think* that's all that's needed for french locale? Couldn't find anything else required to make it work!
2026-05-20 22:20:20 +03:00
nelind 9ebde27540 fix(docs): markdown is tricky ... 2026-05-19 21:30:35 +02:00
nelindandTangled 28a7834304 docs: nel interjection: be right, be good, be fun 2026-05-19 22:18:38 +03:00
Lewis 191da5b311 chore: slight doc tweak
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-19 21:14:28 +03:00
Lewis a5b4ba7d65 feat(docs): init better docs
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-19 20:45:50 +03:00
TrezyandTangled 90dabd8840 docs: start a contributing file to track how to work with the repo
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 19eaccea74 chore: add dev startup to justfile
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled f620a6bc43 chore: add mailpit for local dev
Captures outbound emails, greatly simplifying local dev.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 04f370aaa1 chore: add frontend service to docker compose
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 3fd8f7ebbf chore: add app service with cargo-watch
Spins up a container with Tranquil running, but automatically uses incremental rebuilds for local dev.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 0afcb2ee28 chore: add plc directory for local dev
Prevents us from polluting the real PLC directory.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 8bd556f65b chore: add db health check to docker compose
Safer wafe to depend on database startup.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled cc92594506 chore: add traefik for local dev certs and routing
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 76f22b801b chore: allow native certs during local dev
Allows Traefik certs to be trusted by Tranquil.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 021b7dbec4 chore: add profiles to docker compose
Allows us to spin up groups from the compose file

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:57 +03:00
nelind eb034cb8b3 fix(ci): dont pass cachix cache name as a secret so it doesnt get redacted from CI logs 2026-05-17 17:07:34 +02:00
Lewis bdaf510898 build: bump workspace to 0.6.2
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 12:32:05 +03:00
Lewis deb2502112 feat(pds): phantom-file self-heal goes in scheduled compaction + reachability walk
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 12:32:05 +03:00
Lewis 1815ddba9f feat(gauntlet): index-backed/hint-backed/readable invariants, ExternalCorruption scenario
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 12:32:05 +03:00
Lewis a7517ed5c9 feat(store): consistency check & repair for orphan hints etc
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 11:50:32 +03:00
Lewis d07d702dd4 feat(store): try to self-heal phantom index entries on compaction
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 11:50:19 +03:00
isabelandTangled 1901b0a630 fix(nix): build against pnpm 11
side note: you may also notice that I have removed the `github:` url for
nixpkgs this is because it save a massive 15mb, is faster and more
reliable than github
See also:
-
<https://nix.dev/manual/nix/stable/protocols/tarball-fetcher#lockable-http-tarball-protocol>
-
<http://web.archive.org/web/20250806225139/https://nix.dev/manual/nix/2.28/protocols/tarball-fetcher#lockable-http-tarball-protocol>
2026-05-14 21:37:28 +03:00
serenityandTangled 58f8d327c1 feat(docs): clarify nix documentation 2026-05-14 16:52:10 +03:00
LewisandTangled a13343e1de fix(oauth): gc tokens in pg in the right order, more exposure of dpop err
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-14 16:51:53 +03:00
Bas van den WollenbergandTangled 60e10af4aa fix(Dockerfile): pnpm v11 build errors
- pnpm doesn't implicitly ignore post-install scripts anymore which means trying to build the docker image now fails. i've explicitly allowed them but idk could also ignore them maybe not sure what they actually do lol

- added `confirmModulesPurge: false` but not sure what it actually does as it's not documented? but either way it's also required for the `pnpm build` step to succeed non-interactively it seems idk weird stuff

- removed msmtp/sendmail while i was at it just like https://tangled.org/tranquil.farm/tranquil-pds/commit/f176f55862bc4c41472f8697cc2ca069c24d5e37
2026-05-14 09:56:07 +03:00
isabelandTangled f176f55862 chore(nix): remove sendmail 2026-05-02 23:32:57 +03:00
LewisandTangled fac9520a16 feat(tranquil-server): email config, tests, fmt
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled eee6fb9ff4 feat(comms): EmailSender, permanent/transient routing
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled 2462d0ab3b feat(tranquil-comms): smtp and dkim signing
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled 85f87f7b28 feat(tranquil-comms): message construction and mx resolution
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled b1d86caa78 feat(tranquil-comms): prework for email
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
Lewis 9b2cfb3a7e fix(tranquil-store): durable-tail recovery + sync semantics
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-30 11:11:28 +03:00
Lewis efd499bb26 fix(tranquil-store): barrier durability + torn-header recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-29 15:35:42 +03:00
nelindandTangled d4dfe838eb fix(ci): use kaniko to build 2026-04-28 23:06:36 +03:00
LewisandTangled af3821514f test(tranquil-pds): same-rkey batch coverage and inductive inverse for in-batch dups
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-28 22:05:03 +03:00
LewisandTangled 8f7aad3756 fix(tranquil-pds): same-rkey batch semantics and firehose lag recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-28 22:05:03 +03:00
LewisandTangled 75b9e3165f refactor(deploy): container-first cleanup, drop debian malware-style install
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-27 00:13:27 +03:00
LewisandTangled ccc9916109 test(tranquil-pds): websocket firehose end-to-end mst verification
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-26 20:11:27 +03:00
LewisandTangled bc8fd66a45 test(tranquil-pds): mst fuzz + repo integrity properties
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-26 20:11:27 +03:00
LewisandTangled 180de29984 fix(tranquil-pds): firehose car carries inductive proof
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-26 20:11:27 +03:00
max.andTangled 0455dc20bd fix(validation): discord usernames may contain periods at start or end 2026-04-25 09:59:12 +03:00
Lewis 2770b9b14a feat(tranquil-store): cargo-fuzz targets with asan+ubsan
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-24 10:50:08 +03:00
Lewis d436597184 feat(tranquil-store): flaky-device scenario, jemalloc heap-prof
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-24 10:50:08 +03:00
Lewis 4cfca6d956 feat(tranquil-store): soak harness driving leak gate, signal tweaks
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-23 08:39:16 +03:00
Lewis 98b94fb170 feat(tranquil-store): leak gate and metrics sampling for gauntlet
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-22 21:00:17 +03:00
Lewis 4fe01cff72 feat(tranquil-store): sweep subcommand with axis override fan-out
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-22 17:23:15 +03:00
Lewis 00c9eb732f fix(tranquil-store): arc-counted cache handles, reader-eviction race
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-21 22:04:24 +03:00
Lewis 6d2d3b4be4 fix(tranquil-store): commit-marker batch replay, batch-boundary rotation
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-21 14:55:15 +03:00
Lewis 9d81e58803 fix(tranquil-store): no more orphan data files, recover torn-hint tails, header-safe resume
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-21 09:59:37 +03:00
Lewis 2afd075496 fix(tranquil-store): atomic record commits, hint-as-truth recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-20 21:00:54 +03:00
Hayden YoungandTangled c74bf967cf fix(auth): don't require 2fa for app passwords 2026-04-20 18:20:20 +03:00
Lewis 1285d5c675 fix(tranquil-store): bound writer fd usage across rotations
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-20 16:50:19 +03:00
Lewis c30d73cd4d test(tranquil-store): migrate some tests to gauntlet
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 23:50:27 +03:00
Lewis 0fab8f2eb9 feat(tranquil-store): tranquil-gauntlet CLI, config overrides, profiles
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 23:50:27 +03:00
Lewis 57336fa124 feat(tranquil-store/gauntlet): new invariants & scenarios
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 10:25:07 +03:00
Lewis ace105899f feat(tranquil-store/gauntlet): concurrent executor, eventlog, fault recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 00:19:08 +03:00
Lewis c80a525e0d feat(tranquil-store/gauntlet): op surface, oracle, workload for eventlog & reads
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-18 10:36:30 +03:00
Lewis 7edb76507b feat(tranquil-store/gauntlet): simulated io fault modes, shrinker, regression dump
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-18 10:36:30 +03:00
LewisandTangled a20e4e05e6 fix(oauth): indigo client send empty assertions
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-17 15:18:26 +03:00
teqandTangled 3e7a199736 docs: nixos and binary cache 2026-04-17 01:32:30 +03:00
teqandTangled 7ed9c8ce59 feat(ci): supply cache by arg 2026-04-17 01:32:30 +03:00
teqandTangled b0c3402a82 feat(ci): add cachix publishing workflows 2026-04-17 01:32:30 +03:00
LewisandTangled b9574f3ec1 fix(lexicons): cache ttl
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-16 19:45:27 +03:00
isabelandTangled baef2be844 feat: allow max prefs to be configured
some people (me) have a large amount of prefs and moving from ref pds to
tranquil ment i lost a few of these. so in this pr i bumped the max
prefs to 1000 and made it configurable via the config
2026-04-16 19:45:17 +03:00
Lewis 1f21b85069 fix(example.toml): lewis forgot about confique
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-16 14:49:57 +03:00
Lewis 09d437b3e3 feat(tranquil-store): gauntlet persistence & restart invariants
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-15 22:28:31 +03:00
Lewis 7f2e83e92f feat(tranquil-store): beginnings of the gauntlet test suite
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-15 20:29:34 +03:00
nelind ec273fa814 feat(nix): cross compiled aarch64 package 2026-04-14 23:26:34 +02:00
Lewis d51bfd59da fix(tranquil-store): exclude 0 refcount blocks from has()
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-14 08:40:21 +03:00
LewisandTangled 8ccdd30cb3 fix(repo): use mst diff instead of full tree walk for obsolete blocks
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-13 17:43:07 +00:00
Lewis 7a67361993 fix(tranquil-store): checkpoint-hint race & missing dedup hints
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-13 11:10:17 +03:00
isabelandTangled cdbbaaccdf fix(nix/frontend): add nodejs 2026-04-12 23:08:39 +00:00
isabelandTangled 55d3b7f83d fix(nix/module): don't import using self 2026-04-12 22:50:00 +00:00
Gavin MoganandTangled f00b0231fb fix(Dockerfile): duplicate named stage causes failure to build 2026-04-12 18:53:21 +00:00
LewisandTangled 1dfbd27cce fix(postgres): semaphore on car endpoint & more efficient query
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-12 17:18:45 +00:00
isabelandTangled bc5e0e0446 build(frontend): use pnpm
deno is evil
2026-04-12 16:01:21 +00:00
Lewis 1c87ef5536 fix(tranquil-store): blockstore tweaks
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-12 17:35:59 +03:00
Lewis 255c7135f9 fix(auth): no bsky chat access when not specifically privileged to have it
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-12 15:56:17 +03:00
LewisandTangled 34f050a1f0 feat(oauth): discoverable passkey authentication
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-12 11:16:58 +00:00
LewisandTangled 52c22060f3 fix(tranquil-store): preserve refcount in hint relocate records
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-12 11:16:58 +00:00
Lewis c250d51978 chore(readme): another small update
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-11 17:23:43 +03:00
Lewis 378cb7a7fb chore(readme): update with contributers and new description of project
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-11 16:40:32 +03:00
Bas van den WollenbergandTangled bf20b4a78f fix: use correct postgres container path for quadlet as well 2026-04-11 07:45:23 +00:00
Lewis 05246aec58 feat(tranquil-store): gc and hardening
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:57:47 +03:00
Lewis 8d0e0c6810 feat(tranquil-store): whole test suite working
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:57:44 +03:00
Lewis 1720de8650 feat(tranquil-store): repository traits on MetastoreClient
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:57:43 +03:00
Lewis a5c68a3506 feat(tranquil-store): metastore
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:57:43 +03:00
Lewis 9ea33def13 feat(tranquil-store): eventlog
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:54:41 +03:00
Lewis a9e3ec63e1 feat(tranquil-store): blockstore
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:54:41 +03:00
HigherOrderLogicandTangled dbc3c23c95 fix(nix): allow fetch Git deps 2026-04-09 21:30:03 +00:00
nekomimi.petandTangled 531ca7c1c0 fix(lexicon): wildcard mime type handling and case insensitivity 2026-04-09 21:28:37 +00:00
nelindandTangled add1ff176b fix(auth): keep lxm optional and dont default to * when no lxm is given in getServiceAuth 2026-04-04 08:52:42 +00:00
nelind bdb200a30c fix(ci): dont run it at all for now. it doesnt work with the currently spindles 2026-04-01 23:45:03 +02:00
nelind e5faba3e73 fix(ci): define CI event run correctly so it actually runs 2026-04-01 23:38:19 +02:00
IslaandTangled 5dc810ceef Further harden systemd service 2026-03-31 22:25:11 +00:00
juneandTangled 6abc0eaca2 feat(plc): support AtprotoLabeler service 2026-03-28 20:54:43 +00:00
nelindandTangled 696940f732 fix(sync): dont keep websockets arround indefinetly if client abruptly closes connection 2026-03-25 16:47:38 +00:00
nelindandTangled f777c288da fix(pds): do service identifier resolution for proxying correctly 2026-03-23 03:00:57 +00:00
teqandTangled 9039648705 fix(nix): generate-lockfile for fix/signal-nix-build-deps 2026-03-23 03:00:23 +00:00
teqandTangled 770a16b043 fix(nix): add hashes for git deps 2026-03-23 03:00:23 +00:00
teqandTangled d37f38e496 fix(nix): add protobuf for spqr 2026-03-23 03:00:23 +00:00
Lewis 282f08f207 feat(rfc): outline tranquil-store idea for comment
Lewis: May this revision serve well! <lu5a@proton.me>
2026-03-22 18:54:51 +02:00
LewisandTangled f07bcb82f7 refactor(sync): extract MST parsing and serialization utilities 2026-03-22 12:51:44 +00:00
LewisandTangled e38343ce09 refactor(oauth): split authorize.rs into domain modules 2026-03-22 12:51:44 +00:00
LewisandTangled e454e99b8a refactor(api): migrate all endpoints to repos accessor pattern 2026-03-22 12:51:44 +00:00
LewisandTangled 19b0ea19c6 refactor(tranquil-pds): consolidate repos, rate limits, validation, and auth helpers 2026-03-22 12:51:44 +00:00
LewisandTangled 318818ad51 feat(signal): add admin UI, frontend, and build changes 2026-03-22 07:14:20 +00:00
LewisandTangled 7c55a5ceb9 feat(signal): add admin endpoints, config, and server wiring 2026-03-22 07:14:20 +00:00
LewisandTangled 9d31ee9ace refactor(comms): extract HTTP retry combinator and integrate signal sender 2026-03-22 07:14:20 +00:00
LewisandTangled e3efcb43ba test(signal): add protocol store integration tests 2026-03-22 07:14:20 +00:00
LewisandTangled eb46824220 feat(signal): add presage client, newtypes, and slot management 2026-03-22 07:14:20 +00:00
465 changed files with 119695 additions and 12998 deletions
+73
View File
@@ -13,9 +13,44 @@ fail-fast = false
test-threads = "num-cpus"
slow-timeout = { period = "30s", terminate-after = 4 }
[profile.sim-pr]
retries = 0
fail-fast = true
test-threads = "num-cpus"
slow-timeout = { period = "60s", terminate-after = 4 }
[profile.sim-nightly]
retries = 0
fail-fast = false
test-threads = "num-cpus"
slow-timeout = { period = "300s", terminate-after = 2 }
[profile.gauntlet-pr]
retries = 0
fail-fast = true
test-threads = "num-cpus"
slow-timeout = { period = "60s", terminate-after = 5 }
[[profile.gauntlet-pr.overrides]]
filter = "binary(gauntlet_smoke)"
slow-timeout = { period = "300s", terminate-after = 2 }
[profile.gauntlet-nightly]
retries = 0
fail-fast = false
test-threads = "num-cpus"
slow-timeout = { period = "600s", terminate-after = 1 }
[profile.gauntlet-soak]
retries = 0
fail-fast = false
test-threads = 1
slow-timeout = { period = "5m", terminate-after = 1000 }
[test-groups]
serial-env-tests = { max-threads = 1 }
heavy-load-tests = { max-threads = 4 }
io-heavy-sim = { max-threads = 2 }
[[profile.default.overrides]]
filter = "test(/import_with_verification/) | test(/plc_migration/)"
@@ -29,6 +64,14 @@ test-group = "serial-env-tests"
filter = "binary(ripple_cluster)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "package(tranquil-config)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
@@ -41,6 +84,28 @@ test-group = "heavy-load-tests"
filter = "binary(repo_lifecycle)"
test-group = "heavy-load-tests"
[[profile.default.overrides]]
filter = "binary(sim_soak)"
slow-timeout = { period = "300s", terminate-after = 10 }
test-group = "io-heavy-sim"
[[profile.default.overrides]]
filter = "binary(metastore_crash) | binary(sim_cross_store) | binary(sim_gc_concurrent) | binary(sim_reachability) | binary(sim_eventlog) | test(/sim_/)"
slow-timeout = { period = "120s", terminate-after = 8 }
test-group = "io-heavy-sim"
[[profile.default.overrides]]
filter = "test(/test_scale_/) | test(/full_backup_and_restore/)"
slow-timeout = { period = "120s", terminate-after = 4 }
[[profile.default.overrides]]
filter = "binary(gauntlet_smoke)"
slow-timeout = { period = "300s", terminate-after = 8 }
[[profile.default.overrides]]
filter = "binary(compaction_restart) | binary(mst_refcount_integrity) | binary(gc_compaction_restart)"
slow-timeout = { period = "120s", terminate-after = 4 }
[[profile.ci.overrides]]
filter = "test(/import_with_verification/) | test(/plc_migration/)"
test-group = "serial-env-tests"
@@ -53,6 +118,14 @@ test-group = "serial-env-tests"
filter = "binary(ripple_cluster)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "package(tranquil-config)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
+3 -1
View File
@@ -1,6 +1,8 @@
/target
target/
.env
.direnv
result
frontend/node_modules/
frontend/dist/
frontend/.pnpm-store
frontend/.npmrc
@@ -1,32 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT seq, did, commit_cid\n FROM repo_seq\n WHERE event_type = 'commit'\n AND prev_cid IS NULL\n AND (blocks_cids IS NULL OR array_length(blocks_cids, 1) IS NULL OR array_length(blocks_cids, 1) = 0)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "commit_cid",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
true
]
},
"hash": "033ed5638ef6e30f1648c1acdbc477c173798cb57eb0127c3a582a9633fa5012"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"describe": {
"columns": [
{
@@ -50,26 +50,36 @@
},
{
"ordinal": 9,
"name": "block_cids",
"type_info": "ByteaArray"
},
{
"ordinal": 10,
"name": "block_data",
"type_info": "ByteaArray"
},
{
"ordinal": 11,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "rev",
"type_info": "Text"
}
@@ -94,8 +104,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811"
"hash": "0349b2af4d21105029ecc2bf81ad7be9987ec17ac088730596849d7a45558e57"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
"query": "SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
true
]
},
"hash": "c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038"
"hash": "053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR email = $1\n ",
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR did = $1\n ",
"describe": {
"columns": [
{
@@ -118,5 +118,5 @@
false
]
},
"hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa"
"hash": "060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE handle = $1",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE handle = $1",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "inbound_migration",
"type_info": "Bool"
}
],
"parameters": {
@@ -57,8 +62,9 @@
false,
true,
true,
false,
false
]
},
"hash": "584bceda60d8b6a02e7dc44d833e3fba13151f36ba9f75c64e33d6cb534cc939"
"hash": "18bbda5582db1b32d02ab8a3eee970c9508b9bd67239c2f936639a9f863b30ff"
}
@@ -1,14 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET deactivated_at = NULL WHERE did = $1",
"query": "UPDATE users SET is_admin = $1 WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "23201d4e26bc650939e30f69fb0bca00d351d057098afebc1017f70a84b4bd22"
"hash": "1e63d287c619a14e5c07d80e8e54193d2964c8b5e6a855256cb80c4d0cd2c6ea"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT t.token, t.expires_at\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "237c2d912e89b7e0e5baa83503a22f158ea1614b5157f6c9e2aba6017fef6b26"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE repo_seq SET blocks_cids = $1 WHERE seq = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"Int8"
]
},
"nullable": []
},
"hash": "3b791fdb8e29043c980963d4d18e1e492c73c39818a8648a7af70555418fb5d1"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
"describe": {
"columns": [
{
@@ -50,33 +50,42 @@
},
{
"ordinal": 9,
"name": "block_cids",
"type_info": "ByteaArray"
},
{
"ordinal": 10,
"name": "block_data",
"type_info": "ByteaArray"
},
{
"ordinal": 11,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "rev",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
@@ -94,8 +103,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b"
"hash": "4702a94384ce938d3f5384bc2106a48500f8749134e6bfd1c619a9151f5d3772"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO backlinks (uri, path, link_to, repo_id)\n SELECT unnest($1::text[]), unnest($2::text[]), unnest($3::text[]), $4\n ON CONFLICT (uri, path) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"TextArray",
"TextArray",
"Uuid"
]
},
"nullable": []
},
"hash": "47149c0577ad9e9b9b089820b0c93417769a4a37affe0e3972e324ec27ec532f"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET deactivated_at = NULL, inbound_migration = FALSE WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "51e029e572777e6a103fd7fd5550494de9d4cac7e3ff84e27ddec1a6aaefc047"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev)\n VALUES ($1, 'sync', $2, $3)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)\n VALUES ($1, 'sync', $2, $3, $4, $5)\n RETURNING seq\n ",
"describe": {
"columns": [
{
@@ -13,12 +13,14 @@
"Left": [
"Text",
"Text",
"Text"
"Text",
"ByteaArray",
"ByteaArray"
]
},
"nullable": [
false
]
},
"hash": "bce9edc597592d2e7d672061883639bcdeff80be85e2ecacb6c75d8c027e6710"
"hash": "5996774d097a484f81353d49e4a99b07c30a003377c924add576bfb48ddde29b"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE comms_queue\n SET status = 'failed'::comms_status,\n attempts = max_attempts,\n last_error = $2,\n updated_at = NOW()\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "5bee4ed5296667e4ca7e1a97aec28d30a470b8aee7b378ec9ca4e34de4faf349"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "code",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "5cee16f49a727d66b5231a8d07d7f4bcb6a1136fbf3e3d249fd33600772ac80f"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token, did, expires_at FROM account_deletion_requests WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "61f489b4fc42f5b0aaea287cde4415da6f5e96b3a0f36216bdc6dea924b09abd"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW()\n ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "state_json",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM repo_seq WHERE created_at < $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz"
]
},
"nullable": []
},
"hash": "746ca87e98f6eaa9c060770c0d1bca6f0d4da60df2dc2db088e57bdc914cf233"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)\n VALUES ($1, $2, 1, TRUE, NOW())\n ON CONFLICT (did) DO UPDATE SET secret_encrypted = $2, verified = TRUE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "7d3a9f0545943bc6a3a14fcd596aac5cc731c8177d74e504606d7e92c7d0c73f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET two_factor_enabled = $1 WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "84a1db51a98402323cb86bc19cd2b737f908222ea3426b8bf47d735aff5b6c75"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE attempts < max_attempts\n AND scheduled_for <= $1\n AND (\n status = 'pending'\n OR (status = 'processing'\n AND updated_at < $1 - INTERVAL '10 minutes')\n )\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
"describe": {
"columns": [
{
@@ -154,5 +154,5 @@
true
]
},
"hash": "8047fda41bd94f819213decb8b3e0aba49a8dbdb10217eefd77e3567f8c9694a"
"hash": "890aa92acdcb0fe2a3bf04d87e1f16a801d271da7cedc32fc42c2ef5b100faae"
}
@@ -0,0 +1,36 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM comms_queue WHERE user_id = $1 AND comms_type = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification",
"channel_verified"
]
}
}
}
]
},
"nullable": []
},
"hash": "89b0292d8d022fad8f9cda07b9a7870ca6a7ebe904b2d580956b0816b50bcdb7"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"describe": {
"columns": [
{
@@ -50,32 +50,43 @@
},
{
"ordinal": 9,
"name": "block_cids",
"type_info": "ByteaArray"
},
{
"ordinal": 10,
"name": "block_data",
"type_info": "ByteaArray"
},
{
"ordinal": 11,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "rev",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
@@ -93,8 +104,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f"
"hash": "89c8ce80d8c52b4668c34f63e48b51cacc7e8ff300958a192378f1d72de04f9a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY updated_at ASC\n OFFSET $2\n )\n ",
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY created_at DESC\n OFFSET $2\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "56cd24903171eddc2ededd9079ffe10937c34e99b0305f25c980ca754da44625"
"hash": "8f4357f7a18ddcf6b686a4555f244d37c35917364b8f917ca6ee2d4030ace742"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n RETURNING seq\n ",
"describe": {
"columns": [
{
@@ -17,7 +17,8 @@
"Text",
"Jsonb",
"TextArray",
"TextArray",
"ByteaArray",
"ByteaArray",
"Text",
"Text"
]
@@ -26,5 +27,5 @@
false
]
},
"hash": "40e848f48afb50f5e4820445aee874a482333e9090ef14ce44db3fd9721cfff0"
"hash": "97dd3cfe72b74b4dac686c030a07f6eb56dabb2df72815f3248760b4e3f7950e"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) as \"count!\"\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "990bf50e60fc5566639c2c12cd968d154d7b0c6863ad69141653135f98fbc998"
}
@@ -0,0 +1,180 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at\n FROM comms_queue\n WHERE user_id = $1 AND comms_type = $2\n ORDER BY created_at DESC\n LIMIT $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "channel: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
},
{
"ordinal": 3,
"name": "comms_type: CommsType",
"type_info": {
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification",
"channel_verified"
]
}
}
}
},
{
"ordinal": 4,
"name": "status: CommsStatus",
"type_info": {
"Custom": {
"name": "comms_status",
"kind": {
"Enum": [
"pending",
"processing",
"sent",
"failed"
]
}
}
}
},
{
"ordinal": 5,
"name": "recipient",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "subject",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "metadata",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "attempts",
"type_info": "Int4"
},
{
"ordinal": 10,
"name": "max_attempts",
"type_info": "Int4"
},
{
"ordinal": 11,
"name": "last_error",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 13,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 14,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 15,
"name": "processed_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification",
"channel_verified"
]
}
}
},
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
true,
false,
false,
false,
true
]
},
"hash": "9f3f2b36f11e9446915d3ca29ef81e4ada0c6a6d72764116dac4f99a4e09785e"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)\n RETURNING seq\n ",
"describe": {
"columns": [
{
@@ -16,7 +16,8 @@
"Text",
"Jsonb",
"TextArray",
"TextArray",
"ByteaArray",
"ByteaArray",
"Text"
]
},
@@ -24,5 +25,5 @@
false
]
},
"hash": "52df8ae809b990fa76e3ac45f37e352a9fc8f92f911a7b9c9be2fd9e8032b18c"
"hash": "a325862f897484bb1a8863fd7a1bc3fd17ab20b6e30498f9900daf80609c413d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.did = $1",
"describe": {
"columns": [
{
@@ -132,5 +132,5 @@
null
]
},
"hash": "a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249"
"hash": "aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
"describe": {
"columns": [
{
@@ -50,26 +50,36 @@
},
{
"ordinal": 9,
"name": "block_cids",
"type_info": "ByteaArray"
},
{
"ordinal": 10,
"name": "block_data",
"type_info": "ByteaArray"
},
{
"ordinal": 11,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "rev",
"type_info": "Text"
}
@@ -94,8 +104,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a"
"hash": "ad784aff9bb5c1f19f90a3b1c3b3694a3e1aa889b0fe3e55cfebb7af8c620256"
}
@@ -0,0 +1,44 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) as \"count!\" FROM comms_queue WHERE user_id = $1 AND comms_type = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification",
"channel_verified"
]
}
}
}
]
},
"nullable": [
null
]
},
"hash": "b364a2b202bab17c0cdc5f70d23b13841b4d9063d94cd0b09268c3dc41824fd2"
}
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT blocks_cids, commit_cid\n FROM repo_seq\n WHERE did = $1 AND rev > $2\n ORDER BY seq DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 1,
"name": "commit_cid",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "c15d538fece4cd7db0056f9e0374a2290e26f5766881fd9dbf4f05788845aac0"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
"describe": {
"columns": [
{
@@ -50,26 +50,36 @@
},
{
"ordinal": 9,
"name": "block_cids",
"type_info": "ByteaArray"
},
{
"ordinal": 10,
"name": "block_data",
"type_info": "ByteaArray"
},
{
"ordinal": 11,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "rev",
"type_info": "Text"
}
@@ -93,8 +103,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903"
"hash": "c47746c256fd8c9d386551f3bd270e9a96fecdb713642d07d60fb4aeb02c8426"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'discoverable', $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Bytea",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM backlinks WHERE uri = ANY($1::text[])",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray"
]
},
"nullable": []
},
"hash": "cffe4c37fe949fbdc3d5cd83ccec5655aae248a0a69dc260d1da9cf1d9ed2c49"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE did = $1",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE did = $1",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "inbound_migration",
"type_info": "Bool"
}
],
"parameters": {
@@ -57,8 +62,9 @@
false,
true,
true,
false,
false
]
},
"hash": "6b51995c40519a63f85c70f29ca8bd6ec1963c8562d78215d980785dc46a6384"
"hash": "f1c4ec28b02d09ffce35aa8249c1747a70c12a3ecfc1ff6ca1847840f770db2f"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, public_key_did_key, private_key_bytes, expires_at, used_at\n FROM reserved_signing_keys WHERE public_key_did_key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "public_key_did_key",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "private_key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 4,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false,
false,
false,
true
]
},
"hash": "f3b07f153284b6dd1f22c098af8628d85dcdf20dd6273443ff98d02b6f5ecbf1"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password_reset_code",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "password_reset_code_expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382"
}
@@ -0,0 +1,21 @@
when:
- event: ["push", "manual"]
branch: ["main"]
engine: nixery
dependencies:
nixpkgs:
- nix
- cachix
- gnugrep
steps:
- name: Authenticate
command: |
cachix authtoken "$CACHIX_AUTH_TOKEN"
- name: Build and push aarch64
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds-aarch64 $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
@@ -0,0 +1,28 @@
when:
- event: ["push", "manual"]
branch: ["main"]
engine: nixery
dependencies:
nixpkgs:
- nix
- cachix
- gnugrep
steps:
- name: Authenticate
command: |
cachix authtoken "$CACHIX_AUTH_TOKEN"
- name: Build and push x86_64
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-frontend $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
- name: Build and push devShell
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix develop $SUBS --profile dev-profile -c true
cachix push tranquil dev-profile
+24 -12
View File
@@ -1,24 +1,36 @@
when:
- event: ["push"]
branches: main
- event: [ "manual" ]
- event: [ "push" ]
branch: [ "main" ]
engine: nixery
dependencies:
nixpkgs:
- podman
- kaniko
environment:
DOCKER_CONFIG: "/kaniko/.docker"
steps:
- name: Create podman config
- name: Configure Kaniko
command: |
mkdir -p ~/.config/containers
echo "unqualified-search-registries = [\"docker.io\"]" >> ~/.config/containers/registries.conf
mkdir -p /kaniko/.docker/
echo "{
\"auths\": {
\"https://atcr.io/v1\":{
\"auth\": \"$ATCR_CREDENTIALS\"
}
}
}" > /kaniko/.docker/config.json
- name: Build image
command: |
podman build . -t tranquil-pds:latest -t "tranquil-pds:$TANGLED_COMMIT_SHA"
- name: Publish image
command: |
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil-pds/tranquil:latest"
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil-pds/tranquil:$TANGLED_COMMIT_SHA"
executor \
--context=$(pwd) \
--ignore-path=$(pwd) \
--dockerfile=$(pwd)/Dockerfile \
--destination="atcr.io/tranquil.farm/tranquil-pds:latest" \
--destination="atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA" \
--push-retry=3 \
--skip-push-permission-check
+135
View File
@@ -0,0 +1,135 @@
# Contributing to Tranquil PDS
## When PRing
In order of importance:
- **You must run your change! Every contribution that says "here's xyz. untested." does not help the project.**
- Relevant tests to your PR must pass. The whole suite doesn't have to be proven to have run, because there are a *ton* of tests and they're quite heavy, but hopefully there are existing tests for whatever you're PRing, and if there aren't, please add those too.
- Run cargo fmt :P
> 🦪 Lewis
>
> Good CI fixes some of these. We should really get around to that.
Things that would also be nice but aren't like, a pain in our side:
- Big changes should be stacked PRs that are broken up into digestible pieces. Those stacked PRs should hopefully be able to be merged individually if necessary.
## Local Development
### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- Add `pds.test` to your hosts file (one-time setup):
```
127.0.0.1 pds.test
```
- **macOS / Linux:** `/etc/hosts`
- **Windows:** `C:\Windows\System32\drivers\etc\hosts`
### Starting the dev environment
```bash
just run-dev
```
This starts the following services via `docker-compose`:
- **Traefik** — HTTPS reverse proxy at `https://pds.test`
- **Backend** — Rust server with `cargo-watch` (auto-rebuilds on file changes)
- **Frontend** — Vite dev server with hot module replacement
- **Postgres** — Database on port 5432
- **PLC Directory** — Local [did-method-plc](https://github.com/did-method-plc/did-method-plc) server for DID registration
- **Mailpit** — Local email server with web UI at [http://localhost:8025](http://localhost:8025)
Once all services are running, open **https://pds.test** in your browser.
### Trusting the self-signed certificate
Traefik generates a self-signed TLS certificate. Your browser will show a security warning on first visit. You can either click through it, or add the certificate to your system trust store for a seamless experience:
**macOS:**
```bash
# Extract the cert from traefik and add it to the system keychain
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 > /tmp/pds-test.pem
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/pds-test.pem
```
**Linux (Debian/Ubuntu):**
```bash
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 | sudo tee /usr/local/share/ca-certificates/pds-test.crt
sudo update-ca-certificates
```
**Linux (Fedora/RHEL):**
```bash
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 | sudo tee /etc/pki/ca-trust/source/anchors/pds-test.pem
sudo update-ca-trust
```
**Windows (PowerShell as Administrator):**
```powershell
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
$cert.Import([System.Text.Encoding]::UTF8.GetBytes((echo | openssl s_client -connect localhost:443 -servername pds.test 2>$null | openssl x509)))
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root", "LocalMachine")
$store.Open("ReadWrite")
$store.Add($cert)
$store.Close()
```
Restart your browser after adding the certificate.
### Stopping the dev environment
```bash
# Stop containers (preserves database + build cache)
docker compose --profile dev down
# Stop and wipe all data (fresh start)
docker compose --profile dev down -v
```
### Direct database access
Postgres is exposed on port 5432:
```bash
psql postgres://postgres:postgres@localhost:5432/pds
```
### How it works
- **Source code** is bind-mounted into the containers so that changes made on the host will be immediately reflected in the application
- **Backend** uses `cargo-watch` to recompile and restart when Rust files change
- **Frontend** uses Vite's HMR for instant browser updates when frontend files change
- **Build cache** (`target/` directory and cargo registry) are stored in Docker volumes, so incremental compilation persists across container restarts
- **Traefik** routes `/`, `/xrpc`, `/oauth`, `/.well-known`, `/u`, and `/health` to the backend; everything else goes to the Vite dev server
- **Mailpit** captures all outgoing email — open [http://localhost:8025](http://localhost:8025) to view verification emails during registration
- **PLC Directory** runs locally so DID registration doesn't hit the real `plc.directory`
### Running the backend natively
If you prefer running the Rust backend outside Docker (faster incremental builds on host), you need:
- Rust toolchain (see `rust-toolchain.toml`)
- `protoc` (`brew install protobuf` on macOS)
- PostgreSQL (start with `docker compose up db`)
Then run:
```bash
cargo run -p tranquil-server -- --config config.toml
```
And start the frontend separately:
```bash
cd frontend && pnpm install && pnpm dev
```
Generated
+1388 -647
View File
File diff suppressed because it is too large Load Diff
+20 -4
View File
@@ -22,10 +22,11 @@ members = [
"crates/tranquil-api",
"crates/tranquil-lexicon",
"crates/tranquil-signal",
"crates/tranquil-store",
]
[workspace.package]
version = "0.4.7"
version = "0.6.3"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -50,13 +51,15 @@ tranquil-server = { path = "crates/tranquil-server" }
tranquil-sync = { path = "crates/tranquil-sync" }
tranquil-oauth-server = { path = "crates/tranquil-oauth-server" }
tranquil-api = { path = "crates/tranquil-api" }
tranquil-signal = { path = "crates/tranquil-signal" }
tranquil-signal = { path = "crates/tranquil-signal", features = ["fjall-store"] }
tranquil-store = { path = "crates/tranquil-store" }
presage = { git = "https://github.com/whisperfish/presage", rev = "fe3ed54c4844ae51c3a9fa49cf80a7816a31a425", default-features = false }
unicode-segmentation = "1"
aes-gcm = "0.10"
arc-swap = "1"
backon = "1"
bincode = { version = "2", features = ["serde"] }
anyhow = "1.0"
@@ -84,6 +87,8 @@ hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
hkdf = "0.12"
hmac = "0.12"
http = "1.4"
hyper = { version = "1", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["server", "server-auto", "server-graceful", "service", "tokio"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
qrcodegen = "1.8"
infer = "0.19"
@@ -91,6 +96,7 @@ ipld-core = "0.4"
iroh-car = "0.5"
jacquard-common = { version = "0.9", features = ["crypto-k256"] }
jacquard-repo = "0.9"
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "pool", "dkim", "tracing"] }
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
k256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] }
metrics = "0.24"
@@ -103,6 +109,10 @@ p384 = { version = "0.13", features = ["ecdsa"] }
rand = "0.8"
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
regex = "1"
rsa = "0.9"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-pemfile = "2"
secrecy = { version = "0.10", features = ["serde"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] }
serde = { version = "1.0", features = ["derive"] }
serde_bytes = "0.11"
@@ -114,8 +124,9 @@ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"
subtle = "2.5"
thiserror = "2.0"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process", "io-util", "fs"] }
tokio-util = "0.7.18"
tokio-util = { version = "0.7.18", features = ["rt"] }
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
totp-rs = { version = "5", features = ["qr"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "cors"] }
@@ -124,7 +135,7 @@ tracing = "0.1"
tracing-subscriber = "0.3"
urlencoding = "2.1"
uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] }
webauthn-rs-proto = "0.5"
zip = { version = "7.0", default-features = false, features = ["deflate"] }
@@ -141,3 +152,8 @@ curve25519-dalek = { git = "https://github.com/signalapp/curve25519-dalek", tag
lto = "fat"
strip = true
codegen-units = 1
panic = "abort"
[profile.bench]
debug = 1
strip = false
+12 -19
View File
@@ -1,10 +1,13 @@
FROM denoland/deno:alpine AS frontend
FROM node:24-alpine AS frontend
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN deno task build
RUN pnpm build
FROM rust:1.92-alpine AS builder
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang protoc
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
WORKDIR /app
ARG SLIM="false"
@@ -29,8 +32,10 @@ COPY crates/tranquil-pds ./crates/tranquil-pds
COPY crates/tranquil-sync ./crates/tranquil-sync
COPY crates/tranquil-api ./crates/tranquil-api
COPY crates/tranquil-oauth-server ./crates/tranquil-oauth-server
COPY crates/tranquil-store ./crates/tranquil-store
COPY crates/tranquil-signal ./crates/tranquil-signal
COPY crates/tranquil-server ./crates/tranquil-server
COPY migrations ./crates/tranquil-pds/migrations
COPY migrations ./migrations
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
if [ "$SLIM" = "true" ]; then \
@@ -40,24 +45,12 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
fi && \
cp target/release/tranquil-server /tmp/tranquil-pds
FROM alpine:3.23 AS signal-cli
RUN apk add --no-cache curl tar
ARG SIGNAL_CLI_VERSION=0.13.24
RUN curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
| tar xz -C /usr/local/bin
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends msmtp ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/bin/msmtp /usr/sbin/sendmail
COPY --from=signal-cli /usr/local/bin/signal-cli /usr/local/bin/signal-cli
VOLUME /var/lib/signal-cli
FROM alpine:3.23
RUN apk add --no-cache ca-certificates
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
COPY migrations /app/migrations
WORKDIR /app
ENV SIGNAL_CLI_CONFIG=/var/lib/signal-cli
ENV SERVER_HOST=0.0.0.0
ENV SERVER_HOST=[::]
ENV SERVER_PORT=3000
EXPOSE 3000
CMD ["tranquil-pds"]
+20
View File
@@ -0,0 +1,20 @@
FROM rust:1.92-alpine
RUN apk add --no-cache \
ca-certificates \
musl-dev \
pkgconfig \
openssl-dev \
openssl-libs-static \
mold \
clang \
protoc
RUN cargo install cargo-watch
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
ENV SQLX_OFFLINE=true
WORKDIR /app
CMD ["cargo", "watch", "-x", "run -p tranquil-server -- --config /app/config.dev.toml"]
+53 -15
View File
@@ -2,17 +2,25 @@
A Personal Data Server for the AT Protocol.
Bluesky runs on a federated protocol called AT Protocol. Your account lives on a PDS, a server that stores your posts, profile, follows, and cryptographic keys. Bluesky hosts one for you at bsky.social, but you can run your own. Self-hosting means you control your data; you're not dependent on any company's servers, and your account + data is actually yours.
"A what for the what?" -> glad you asked: Bluesky, Tangled, and a bunch of other web applications use a federated protocol called AT Protocol (atproto). Your account lives on a PDS, a server that stores your posts, profile, follows, cryptographic keys, et cetera. The beauty is that a PDS is the *only* place your data lives permanently - so you can navigate any atproto app knowing that your data is yours and not getting locked behind any one app's walls.
This particular PDS thrives under harsh conditions. It is a dandelion growing through the cracks in the sidewalk concrete.
We came together to make this PDS to enable and empower our users to better host their data on this shared protocol. All of our decisions as a project are guided by their usefulness to the community: PDS hosters and end-users both.
It has full compatibility with Bluesky's reference PDS.
Comparatively: Bluesky the company created a "reference PDS" that we can self-host quite easily, and that's great, but Bluesky has an incentive to make software for themselves first & foremost, then secondly their software can be useful for us self-hosters. In contrast, Tranquil is not from a company, and will never be.
## What's different about Tranquil PDS
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), SSO login and signup, did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, repo browsing, and admin.
It is a superset of the reference PDS, including:
- passkeys and 2FA: WebAuthn/FIDO2, TOTP, backup codes, trusted devices
- SSO login and signup
- did:web support: PDS-hosted subdomains or bring-your-own
- multi-channel communication: you can be notified via email, discord, telegram, and signal for verification and alerts
- granular OAuth scopes with a consent UI that allows unchecking specific scopes
- app passwords with the same granular permission scope system as OAuth
- account delegation: letting others manage an account with configurable permission levels
- a built-in web UI for account management, repo browsing, and admin
The PDS itself is a single binary with no nodeJS runtime. However, at time of writing, Tranquil requires postgres running separately. Blobs are stored on the local filesystem by default (S3 optional). Valkey is also optional (as an alternative to the built-in cache).
Unlike the ref PDS, Tranquil is a single binary with no nodejs runtime. That said, at time of writing, Tranquil does require postgres running separately.
## Quick Start
@@ -38,33 +46,63 @@ just test
just lint
```
Nix users can enter a devshell with `nix develop`, or `direnv allow` to auto-enter via the bundled `.envrc`. Pre-built artifacts including the devshell are available from our [binary cache](docs/2_INSTALL_NIX.md#binary-cache).
## Production Deployment
### Quick Deploy (Docker/Podman Compose)
Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`.
`docker-compose.prod.yaml` pulls the prebuilt image `atcr.io/tranquil.farm/tranquil-pds:latest`. Sign in to the registry first with `podman login atcr.io`. The Containers guide covers building from source.
```bash
cp example.toml config.toml
```
Edit `config.toml` with your values and generate secrets with `openssl rand -base64 48`. Set the postgres password to match `docker-compose.prod.yaml`. nginx needs a TLS certificate before it starts, so follow the wildcard cert steps in the [Containers guide](docs/2_INSTALL_CONTAINERS.md).
```bash
podman-compose -f docker-compose.prod.yaml up -d
```
### Installation Guides
- [Debian](docs/install-debian.md)
- [Containers](docs/install-containers.md)
- [Kubernetes](docs/install-kubernetes.md)
- [Nix](docs/2_INSTALL_NIX.md)
- [Containers](docs/2_INSTALL_CONTAINERS.md)
## Maintainers to ping
## Community
- [@lewis.moe](https://bsky.app/profile/did:plc:3fwecdnvtcscjnrx2p4n7alz)
- [@nel.pet](https://bsky.app/profile/did:plc:h5wsnqetncv6lu2weom35lg2)
### "Let's connect!" or whatever linkedin-types say
## Thanks
We currently don't have a shared space to chat and organize Tranquil things, but we're very interested in changing that in the near future. What do you suggest? Anything but a discord server.
This project is very grateful to [@nonbinary.computer](https://bsky.app/profile/did:plc:yfvwmnlztr4dwkb7hwz55r2g), [@juli.ee](https://bsky.app/profile/did:plc:7vimlesenouvuaqvle42yhvo), [@mary.my.id](https://bsky.app/profile/did:plc:ia76kvnndjutgedggx2ibrem), and [@baileytownsend.dev](https://bsky.app/profile/did:plc:rnpkyqnmsw4ipey6eotbdnnf) for their help and their code to lean on.
### Core team
- [@oyster.cafe](https://tangled.org/did:plc:3fwecdnvtcscjnrx2p4n7alz)
- [@nel.pet](https://tangled.org/did:plc:h5wsnqetncv6lu2weom35lg2)
### Amazing contributors
- [@isabelroses.com](https://tangled.org/did:plc:qxichs7jsycphrsmbujwqbfb)
- [@quilling.dev](https://tangled.org/did:plc:jrtgsidnmxaen4offglr5lsh)
- [@koi.rip](https://tangled.org/did:plc:b26ewgkrnx3yvsp2cdao3ntu)
- [@bas.sh](https://tangled.org/did:plc:c52wep6lj4sfbsqiz3yvb55h)
- [@nekomimi.pet](https://tangled.org/did:plc:ttdrpj45ibqunmfhdsb4zdwq)
- [@islacant.win](https://tangled.org/did:plc:aut6evcs6d6ngaunqgfhdzzu)
- [@a.starrysky.fyi](https://tangled.org/did:plc:uuyqs6y3pwtbteet4swt5i5y)
- [@sans-self.org](https://tangled.org/did:plc:wydyrngmxbcsqdvhmd7whmye)
- [@tachyonism.tngl.sh](https://tangled.org/did:plc:w6qiwij62bmdugsd3gemhpy2)
- [@trezy.codes](https://tangled.org/did:plc:4jrld6fwpnwqehtce56qshzv)
- Could be your name here too!
### Tranquil PDS instances in the wild!
- [Tranquil Farm](https://tranquil.farm)
- Your instance here!! Don't be a stranger.
### Special thanks
This project is very grateful to [@nonbinary.computer](https://tangled.org/did:plc:yfvwmnlztr4dwkb7hwz55r2g), [@juliet.paris](https://tangled.org/did:plc:hs3aly5l26pozymy4b6hz7ae), [@mary.my.id](https://tangled.org/did:plc:ia76kvnndjutgedggx2ibrem), [@baileytownsend.dev](https://tangled.org/did:plc:rnpkyqnmsw4ipey6eotbdnnf), and [@ptr.pet](https://tangled.org/did:plc:dfl62fgb7wtjj3fcbb72naae) for their help and their code to lean on.
## License
AGPL-3.0-or-later. Documentation is CC BY-SA 4.0. See [LICENSE](LICENSE) for details.
+169
View File
@@ -0,0 +1,169 @@
tranquil-store: embedded storage engine for Tranquil PDS
RFC draft, 2026-03-22
By Lewis!
-- TLDR --
Add an embedded storage engine as an alternative to postgres (and leapfrog SQLite-per-actor)
that treats Tranquil's 3 types of storage workloads as 3 separate problems:
- BlockStore: bitcask-esque append log for immutable CID-keyed blocks [4]
- MetaStore: Fjall LSM keyspaces for mutable metadata [5]
- EventLog: segmented append log for the firehose
Group commit across users, content dedup, sub-ms firehose delivery.
We will use deterministic simulation testing [18][19].
Postgres will of course stay as the existing alternative backend.
-- Intro --
The ref PDS hits structural limits around 300k accounts [2].
SQLite-per-actor means no cross-user write batching.
tranquil-store is an embedded rust library. It lives in-process, no external deps.
Postgres remains supported; we plan to enable a storage transition path
such that users can seamlessly snapshot-n-switch between the backends.
The BlockStore is a bitcask-style append log [4] with a Fjall key index [5] that
maps each CID to a (file, offset, length) tuple. We use key-value separation
as per WiscKey [6]. Because blocks are immutable and keyed by CID, the value log
never needs compaction. An LRU hot tier keeps frequently-accessed blocks in
mem, and hint files allow fast index reconstruction on restart [4].
The main throughput enabler is group commit [7]. The ref PDS fsyncs once per user
per mutation [1], but BlockStore batches all concurrent commits into a single
write-and-sync cycle.
Content dedup occurs naturally: identical MST subtrees across users share
one CID-keyed block instead of N copies [3].
MetaStore uses Fjall [5] keyspaces for all mutable data. We chose Fjall over
redb and LMDB because both of those are single-writer [8][9]. Each keyspace
compacts independently.
For cross-store atomicity we use an intent log. Each mutation writes a single
intent record containing the BlockStore refcount updates, MetaStore changes,
and the serialized EventLog payload, fsynced via the group commit. After fsync,
the changes are applied to MetaStore and the event is appended to the EventLog,
then the intent is marked committed. Recovery replays any incomplete intents,
re-applying both metadata changes and event appends. This gives us crash-atomic
mutations across all three stores without full MVCC, since mutations
are already serialized per-user [3].
EventLog stores the firehose as segmented append-only files. Live subscribers
receive events via tokio broadcast, and consumers that are catching up will
read from mmap'ed segments [10]. Each event receives a monotonic u64 sequence number.
Segment headers store the base sequence number; a per-segment index maps sequence
ranges to byte offsets. This decouples consumer cursors from physical layout,
allowing transparent addition of per-segment zstd compression per the loom-v2
spec [11] without invalidating checkpoints. Retention is just deleting old
segments! :P
GC uses refcounted key index entries. GC is epoch-gated by the group commit
cycle: a block is only eligible for collection if its refcount reached zero in
a prior completed commit cycle. This prevents races between concurrent dedup
(which skips the block write but increments the refcount in the same batch) and
collection. Blocks past the epoch gate are collected by rewriting any data
files that fall below a liveness threshold.
For serialization we use postcard on disk and rkyv [12] for in-mem caches
only. All data files carry a version tag.
Memory is divided into fixed slices from a configurable total budget: Fjall
block cache, BlockStore hot tier, and CID index each receive a configured
percentage. Actual usage per component is exposed as metrics. The EventLog's
mmap pages live in the OS page cache and are excluded from the budget.
Backup acquires the group commit lock, which quiesces all writes at the next
commit boundary. Under the lock, the system notes the EventLog position, the
BlockStore file list, and takes a Fjall snapshot, then releases the lock.
Sealed data files and segments are immutable and can be copied without
co-ordination after the snapshot. The quiesce window is bounded by one commit
cycle. Point-in-time recovery replays the EventLog against a prior snapshot.
For continuous replication, a background process tails the EventLog and copies
sealed files to remote storage.
-- Runtime --
The storage core runs on tokio. It is synchronous internally, accessed through
dedicated handler threads that communicate via async channels [13]. Requests
are dispatched by hashing the DID, which gives us per-user write serialization
without locks. Global operations use round-robin. All disk IO goes through
pread/pwrite directly [13].
We rejected io_uring for three reasons: it creates orphan kernel operations
when futures are cancelled [22], it is blocked by default in both Docker [16]
and Podman [17] seccomp profiles, and it accounts for 60% of Google's kernel
vulnerability rewards [15].
We also rejected thread-per-core runtimes (glommio, etc.) because they are
incompatible with the tokio ecosystem. DID-sharded handler threads give us
the same shared-nothing property without a runtime split.
-- Testing --
We use deterministic simulation testing, following FoundationDB [18] and
TigerBeetle's VOPR [19]. All IO sits behind a StorageIO trait, and tests use an
in-memory implementation that injects faults: partial writes, bit flips, sync
failures, and misdirected writes. A single seed controls the entire fault
schedule, so any failure reproduces exactly [20][21].
-- Why these choices --
Bitcask for blocks:
Key-val separation [6] using Bitcask [4] for immutable CID blocks:
O(1) writes, O(1) reads, zero write amplification, & no compaction!
Fjall for metadata:
Only pure-Rust embedded engine with concurrent writers [5].
Otherwise we'd write our own.
Segmented log for events:
Write once -> scan forward -> delete by age.
Quite straightforward!
Postcard on disk:
rkyv is apparently faster [12] but couples on-disk format to library version.
Tokio & handler threads:
spawn_blocking & pread matches io_uring without security/compat costs [13][14][15][16].
Deterministic simulation:
Catches bug classes conventional testing can't reach [18][19].
StorageIO trait is needed anyway; but being harness-first is a one-time cost [20][21].
-- References --
[1] Bluesky PDS SQLite migration. github.com/bluesky-social/atproto/pull/1705
[2] G. Orosz. Building Bluesky: a Distributed Social Network. Pragmatic Engineer, April 2024.
newsletter.pragmaticengineer.com/p/bluesky
K. Suder. Introduction to AT Protocol. August 2025. mackuba.eu/2025/08/20/introduction-to-atproto
Bluesky PDS "Going to Production" guide. atproto.com/guides/going-to-production
[3] AT Protocol repository spec. atproto.com/specs/repository
[4] Bitcask: A Log-Structured Hash Table for Fast KV Data. Riak, 2010. riak.com/assets/bitcask-intro.pdf
[5] Fjall: LSM-based embedded storage engine. github.com/fjall-rs/fjall
[6] Lu et al. WiscKey: Separating Keys from Values in SSD-Conscious Storage. USENIX FAST 2016.
usenix.org/conference/fast16/technical-sessions/presentation/lu
[7] Phil Eaton. A Write-Ahead Log Is Not a Universal Part of Durability. July 2024.
notes.eatonphil.com/2024-07-01-a-write-ahead-log-is-not-a-universal-part-of-durability.html
[8] redb design document. github.com/cberner/redb/blob/master/docs/design.md
[9] LMDB source repository. github.com/LMDB/lmdb
[10] Crotty et al. Are You Sure You Want to Use MMAP in Your DBMS? CIDR 2022.
cs.brown.edu/people/acrotty/pubs/p13-crotty.pdf
[11] ybzeek. RFC: com.atproto.sync.getZstdStream (zstd-compressed relay streams).
github.com/bluesky-social/atproto/discussions/4582
[12] rkyv: zero-copy deserialization framework for Rust. rkyv.org
[13] Tonbo. Exploring Better Async Rust Disk IO. tonbo.io/blog/exploring-better-async-rust-disk-io
[14] Iroh. Async Rust Challenges in Iroh. iroh.computer/blog/async-rust-challenges-in-iroh
[15] Google restricting io_uring. phoronix.com/news/Google-Restricting-IO_uring
[16] Docker 4.42.0 and io_uring. forums.docker.com/t/4-42-0-and-io-uring/148620
[17] Podman io_uring discussion. github.com/containers/podman/discussions/27772
[18] FoundationDB simulation testing. apple.github.io/foundationdb/testing.html
[19] TigerBeetle VOPR. tigerbeetle.com/blog/2023-07-06-simulation-testing-for-liveness
[20] DST in Rust (S2). s2.dev/blog/dst
[21] Phil Eaton. What's the big deal about Deterministic Simulation Testing? August 2024.
notes.eatonphil.com/2024-08-20-deterministic-simulation-testing.html
[22] Tonbo. Async Rust Is Not Safe with io_uring. tonbo.io/blog/async-rust-is-not-safe-with-io-uring
Thank you for reading! Let's do some great work together.
+30
View File
@@ -0,0 +1,30 @@
[server]
hostname = "pds.test"
allow_http_proxy = true
invite_code_required = false
disable_rate_limiting = true
[frontend]
enabled = true
dir = "/app/frontend/public"
[database]
url = "postgres://postgres:postgres@db:5432/pds"
[storage]
path = "/var/lib/tranquil-pds/blobs"
[plc]
directory_url = "http://plc:2582"
[email]
from_address = "noreply@pds.test"
from_name = "Tranquil PDS (Dev)"
[email.smarthost]
host = "mailpit"
port = 1025
tls = "none"
[secrets]
allow_insecure = true
+3
View File
@@ -12,12 +12,15 @@ tranquil-db = { workspace = true }
tranquil-db-traits = { workspace = true }
tranquil-lexicon = { workspace = true, features = ["resolve"] }
tranquil-scopes = { workspace = true }
tranquil-signal = { workspace = true }
anyhow = { workspace = true }
axum = { workspace = true }
backon = { workspace = true }
base32 = { workspace = true }
base64 = { workspace = true }
image = { workspace = true }
qrcodegen = { workspace = true }
bcrypt = { workspace = true }
bs58 = { workspace = true }
bytes = { workspace = true }
+9 -7
View File
@@ -12,7 +12,6 @@ use tranquil_pds::auth::{Auth, NotTakendown, Permissive};
use tranquil_pds::state::AppState;
const APP_BSKY_NAMESPACE: &str = "app.bsky";
const MAX_PREFERENCES_COUNT: usize = 100;
const MAX_PREFERENCE_SIZE: usize = 10_000;
const PERSONAL_DETAILS_PREF: &str = "app.bsky.actor.defs#personalDetailsPref";
const DECLARED_AGE_PREF: &str = "app.bsky.actor.defs#declaredAgePref";
@@ -34,13 +33,13 @@ pub struct GetPreferencesOutput {
}
pub async fn get_preferences(State(state): State<AppState>, auth: Auth<Permissive>) -> Response {
let has_full_access = auth.permissions().has_full_access();
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&auth.did).await {
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
Ok(Some(id)) => id,
_ => {
return ApiError::InternalError(Some("User not found".into())).into_response();
}
};
let prefs = match state.infra_repo.get_account_preferences(user_id).await {
let prefs = match state.repos.infra.get_account_preferences(user_id).await {
Ok(rows) => rows,
Err(_) => {
return ApiError::InternalError(Some("Failed to fetch preferences".into()))
@@ -92,18 +91,20 @@ pub async fn put_preferences(
auth: Auth<NotTakendown>,
Json(input): Json<PutPreferencesInput>,
) -> Response {
let max_preferences_count: usize = tranquil_config::get().server.max_preferences_count;
let has_full_access = auth.permissions().has_full_access();
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&auth.did).await {
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
Ok(Some(id)) => id,
_ => {
return ApiError::InternalError(Some("User not found".into())).into_response();
}
};
if input.preferences.len() > MAX_PREFERENCES_COUNT {
if input.preferences.len() > max_preferences_count {
return ApiError::InvalidRequest(format!(
"Too many preferences: {} exceeds limit of {}",
input.preferences.len(),
MAX_PREFERENCES_COUNT
max_preferences_count
))
.into_response();
}
@@ -188,7 +189,8 @@ pub async fn put_preferences(
.collect();
if state
.infra_repo
.repos
.infra
.replace_namespace_preferences(user_id, APP_BSKY_NAMESPACE, prefs_to_save)
.await
.is_err()
@@ -19,7 +19,8 @@ pub async fn delete_account(
) -> Result<Json<EmptyResponse>, ApiError> {
let did = &input.did;
let (user_id, handle) = state
.user_repo
.repos
.user
.get_id_and_handle_by_did(did)
.await
.log_db_err("in delete_account")?
@@ -27,7 +28,8 @@ pub async fn delete_account(
.map(|row| (row.id, row.handle))?;
state
.user_repo
.repos
.user
.admin_delete_account_complete(user_id, did)
.await
.log_db_err("deleting account")?;
@@ -31,7 +31,8 @@ pub async fn send_email(
return Err(ApiError::InvalidRequest("content is required".into()));
}
let user = state
.user_repo
.repos
.user
.get_by_did(&input.recipient_did)
.await
.log_db_err("in send_email")?
@@ -45,7 +46,8 @@ pub async fn send_email(
.clone()
.unwrap_or_else(|| format!("Message from {}", hostname));
let result = state
.infra_repo
.repos
.infra
.enqueue_comms(
Some(user_id),
tranquil_db_traits::CommsChannel::Email,
+19 -10
View File
@@ -69,7 +69,8 @@ pub async fn get_account_info(
Query(params): Query<GetAccountInfoParams>,
) -> Result<Json<AccountInfo>, ApiError> {
let account = state
.infra_repo
.repos
.infra
.get_admin_account_info_by_did(&params.did)
.await
.log_db_err("in get_account_info")?
@@ -98,7 +99,8 @@ pub async fn get_account_info(
async fn get_invited_by(state: &AppState, user_id: uuid::Uuid) -> Option<InviteCodeInfo> {
let code = state
.infra_repo
.repos
.infra
.get_invite_code_used_by_user(user_id)
.await
.ok()??;
@@ -111,7 +113,8 @@ async fn get_invites_for_user(
user_id: uuid::Uuid,
) -> Option<Vec<InviteCodeInfo>> {
let invite_codes = state
.infra_repo
.repos
.infra
.get_invites_created_by_user(user_id)
.await
.ok()?;
@@ -123,7 +126,8 @@ async fn get_invites_for_user(
let code_strings: Vec<String> = invite_codes.iter().map(|ic| ic.code.clone()).collect();
let uses = state
.infra_repo
.repos
.infra
.get_invite_code_uses_batch(&code_strings)
.await
.ok()?;
@@ -154,10 +158,11 @@ async fn get_invites_for_user(
}
async fn get_invite_code_info(state: &AppState, code: &str) -> Option<InviteCodeInfo> {
let info = state.infra_repo.get_invite_code_info(code).await.ok()??;
let info = state.repos.infra.get_invite_code_info(code).await.ok()??;
let uses = state
.infra_repo
.repos
.infra
.get_invite_code_uses(code)
.await
.ok()
@@ -197,7 +202,8 @@ pub async fn get_account_infos(
let dids_typed: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
let accounts = state
.infra_repo
.repos
.infra
.get_admin_account_infos_by_dids(&dids_typed)
.await
.log_db_err("fetching account infos")?;
@@ -205,7 +211,8 @@ pub async fn get_account_infos(
let user_ids: Vec<uuid::Uuid> = accounts.iter().map(|u| u.id).collect();
let all_invite_codes = state
.infra_repo
.repos
.infra
.get_invite_codes_by_users(&user_ids)
.await
.unwrap_or_default();
@@ -217,7 +224,8 @@ pub async fn get_account_infos(
let all_invite_uses = if !all_codes.is_empty() {
state
.infra_repo
.repos
.infra
.get_invite_code_uses_batch(&all_codes)
.await
.unwrap_or_default()
@@ -226,7 +234,8 @@ pub async fn get_account_infos(
};
let invited_by_map: HashMap<uuid::Uuid, String> = state
.infra_repo
.repos
.infra
.get_invite_code_uses_by_users(&user_ids)
.await
.unwrap_or_default()
+3 -2
View File
@@ -11,6 +11,7 @@ pub use info::{
};
pub use search::{SearchAccountsOutput, SearchAccountsParams, search_accounts};
pub use update::{
UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput,
update_account_email, update_account_handle, update_account_password,
SetAdminStatusInput, UpdateAccountEmailInput, UpdateAccountHandleInput,
UpdateAccountPasswordInput, set_admin_status, update_account_email, update_account_handle,
update_account_password,
};
@@ -51,15 +51,14 @@ pub async fn search_accounts(
Query(params): Query<SearchAccountsParams>,
) -> Result<Json<SearchAccountsOutput>, ApiError> {
let limit = params.limit.clamp(1, 100);
let email_filter = params.email.as_deref().map(|e| format!("%{}%", e));
let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h));
let cursor_did: Option<Did> = params.cursor.as_ref().and_then(|c| c.parse().ok());
let rows = state
.user_repo
.repos
.user
.search_accounts(
cursor_did.as_ref(),
email_filter.as_deref(),
handle_filter.as_deref(),
params.email.as_deref(),
params.handle.as_deref(),
limit + 1,
)
.await
+44 -11
View File
@@ -1,6 +1,6 @@
use axum::{Json, extract::State};
use serde::Deserialize;
use tracing::{error, warn};
use tracing::{error, info, warn};
use tranquil_pds::api::EmptyResponse;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Admin, Auth};
@@ -30,7 +30,8 @@ pub async fn update_account_email(
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state
.user_repo
.repos
.user
.admin_update_email(&account_did, email)
.await
{
@@ -71,9 +72,10 @@ pub async fn update_account_handle(
} else {
input_handle.to_string()
};
let old_handle = state.user_repo.get_handle_by_did(did).await.ok().flatten();
let old_handle = state.repos.user.get_handle_by_did(did).await.ok().flatten();
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(did)
.await
.ok()
@@ -81,14 +83,16 @@ pub async fn update_account_handle(
.ok_or(ApiError::AccountNotFound)?;
let handle_for_check: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
if let Ok(true) = state
.user_repo
.repos
.user
.check_handle_exists(&handle_for_check, user_id)
.await
{
return Err(ApiError::HandleTaken);
}
match state
.user_repo
.repos
.user
.admin_update_handle(did, &handle_for_check)
.await
{
@@ -146,13 +150,11 @@ pub async fn update_account_password(
if password.is_empty() {
return Err(ApiError::InvalidRequest("password is required".into()));
}
let password_hash = bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})?;
let password_hash = crate::common::hash_or_internal_error(password)?;
match state
.user_repo
.repos
.user
.admin_update_password(did, &password_hash)
.await
{
@@ -164,3 +166,34 @@ pub async fn update_account_password(
}
}
}
#[derive(Deserialize)]
pub struct SetAdminStatusInput {
pub did: Did,
pub admin: bool,
}
pub async fn set_admin_status(
State(state): State<AppState>,
auth: Auth<Admin>,
Json(input): Json<SetAdminStatusInput>,
) -> Result<Json<EmptyResponse>, ApiError> {
info!(
actor = %auth.did,
target = %input.did,
admin = input.admin,
"admin status change"
);
state
.repos
.user
.set_admin_status(&input.did, input.admin)
.await
.map_err(|e| {
error!("DB error setting admin status: {:?}", e);
ApiError::InternalError(None)
})?;
Ok(Json(EmptyResponse {}))
}
+32 -16
View File
@@ -53,7 +53,8 @@ pub async fn get_server_config(
];
let rows = state
.infra_repo
.repos
.infra
.get_server_configs(keys)
.await
.log_db_err("fetching server config")?;
@@ -86,7 +87,8 @@ pub async fn update_server_config(
));
}
state
.infra_repo
.repos
.infra
.upsert_server_config("server_name", trimmed)
.await
.log_db_err("upserting server_name")?;
@@ -95,13 +97,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.primary_color {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("primary_color")
.await
.log_db_err("deleting primary_color")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("primary_color", color)
.await
.log_db_err("upserting primary_color")?;
@@ -115,13 +119,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.primary_color_dark {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("primary_color_dark")
.await
.log_db_err("deleting primary_color_dark")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("primary_color_dark", color)
.await
.log_db_err("upserting primary_color_dark")?;
@@ -135,13 +141,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.secondary_color {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("secondary_color")
.await
.log_db_err("deleting secondary_color")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("secondary_color", color)
.await
.log_db_err("upserting secondary_color")?;
@@ -155,13 +163,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.secondary_color_dark {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("secondary_color_dark")
.await
.log_db_err("deleting secondary_color_dark")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("secondary_color_dark", color)
.await
.log_db_err("upserting secondary_color_dark")?;
@@ -174,7 +184,8 @@ pub async fn update_server_config(
if let Some(ref logo_cid) = req.logo_cid {
let old_logo_cid = state
.infra_repo
.repos
.infra
.get_server_config("logo_cid")
.await
.ok()
@@ -189,13 +200,16 @@ pub async fn update_server_config(
if let Some(old_cid_str) = should_delete_old {
match CidLink::new(old_cid_str) {
Ok(old_cid) => {
if let Ok(Some(storage_key)) =
state.infra_repo.get_blob_storage_key_by_cid(&old_cid).await
if let Ok(Some(storage_key)) = state
.repos
.infra
.get_blob_storage_key_by_cid(&old_cid)
.await
{
if let Err(e) = state.blob_store.delete(&storage_key).await {
error!("Failed to delete old logo blob from storage: {:?}", e);
}
if let Err(e) = state.infra_repo.delete_blob_by_cid(&old_cid).await {
if let Err(e) = state.repos.infra.delete_blob_by_cid(&old_cid).await {
error!("Failed to delete old logo blob record: {:?}", e);
}
}
@@ -211,13 +225,15 @@ pub async fn update_server_config(
if logo_cid.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("logo_cid")
.await
.log_db_err("deleting logo_cid")?;
} else {
state
.infra_repo
.repos
.infra
.upsert_server_config("logo_cid", logo_cid)
.await
.log_db_err("upserting logo_cid")?;
+13 -7
View File
@@ -24,7 +24,7 @@ pub async fn disable_invite_codes(
Json(input): Json<DisableInviteCodesInput>,
) -> Result<Json<EmptyResponse>, ApiError> {
if let Some(codes) = &input.codes
&& let Err(e) = state.infra_repo.disable_invite_codes_by_code(codes).await
&& let Err(e) = state.repos.infra.disable_invite_codes_by_code(codes).await
{
error!("DB error disabling invite codes: {:?}", e);
}
@@ -32,7 +32,8 @@ pub async fn disable_invite_codes(
let accounts_typed: Vec<tranquil_types::Did> =
accounts.iter().filter_map(|a| a.parse().ok()).collect();
if let Err(e) = state
.infra_repo
.repos
.infra
.disable_invite_codes_by_account(&accounts_typed)
.await
{
@@ -87,7 +88,8 @@ pub async fn get_invite_codes(
};
let codes_rows = state
.infra_repo
.repos
.infra
.list_invite_codes(params.cursor.as_deref(), limit, sort_order)
.await
.log_db_err("fetching invite codes")?;
@@ -96,7 +98,8 @@ pub async fn get_invite_codes(
let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect();
let creator_dids: std::collections::HashMap<uuid::Uuid, tranquil_types::Did> = state
.infra_repo
.repos
.infra
.get_user_dids_by_ids(&user_ids)
.await
.unwrap_or_default()
@@ -108,7 +111,8 @@ pub async fn get_invite_codes(
} else {
common::group_invite_uses_by_code(
state
.infra_repo
.repos
.infra
.get_invite_code_uses_batch(&code_strings)
.await
.unwrap_or_default(),
@@ -168,7 +172,8 @@ pub async fn disable_account_invites(
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state
.user_repo
.repos
.user
.set_invites_disabled(&account_did, true)
.await
{
@@ -200,7 +205,8 @@ pub async fn enable_account_invites(
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state
.user_repo
.repos
.user
.set_invites_disabled(&account_did, false)
.await
{
+3 -1
View File
@@ -2,15 +2,17 @@ pub mod account;
pub mod config;
pub mod invite;
pub mod server_stats;
pub mod signal;
pub mod status;
pub use account::{
delete_account, get_account_info, get_account_infos, search_accounts, send_email,
update_account_email, update_account_handle, update_account_password,
set_admin_status, update_account_email, update_account_handle, update_account_password,
};
pub use config::{get_server_config, update_server_config};
pub use invite::{
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
};
pub use server_stats::get_server_stats;
pub use signal::{get_signal_status, link_signal_device, unlink_signal_device};
pub use status::{get_subject_status, update_subject_status};
@@ -17,10 +17,10 @@ pub async fn get_server_stats(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<ServerStatsOutput>, ApiError> {
let user_count = state.user_repo.count_users().await.unwrap_or(0);
let repo_count = state.repo_repo.count_repos().await.unwrap_or(0);
let record_count = state.repo_repo.count_all_records().await.unwrap_or(0);
let blob_storage_bytes = state.blob_repo.sum_blob_storage().await.unwrap_or(0);
let user_count = state.repos.user.count_users().await.unwrap_or(0);
let repo_count = state.repos.repo.count_repos().await.unwrap_or(0);
let record_count = state.repos.repo.count_all_records().await.unwrap_or(0);
let blob_storage_bytes = state.repos.blob.sum_blob_storage().await.unwrap_or(0);
Ok(Json(ServerStatsOutput {
user_count,
+169
View File
@@ -0,0 +1,169 @@
use axum::{Json, extract::State};
use base64::{Engine, engine::general_purpose::STANDARD};
use image::{ImageBuffer, Luma};
use serde::Serialize;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Admin, Auth};
use tranquil_pds::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalStatusOutput {
pub linked: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalLinkOutput {
pub qr_base64: String,
}
pub async fn get_signal_status(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<SignalStatusOutput>, ApiError> {
let linked = match &state.signal_sender {
Some(slot) => slot.is_linked().await,
None => false,
};
Ok(Json(SignalStatusOutput { linked }))
}
pub async fn link_signal_device(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<SignalLinkOutput>, ApiError> {
let slot = state
.signal_sender
.as_ref()
.ok_or_else(|| ApiError::InvalidRequest("Signal is not enabled".into()))?;
if slot.is_linked().await {
return Err(ApiError::InvalidRequest(
"Signal device already linked".into(),
));
}
let (generation, link_cancel) = slot.begin_link().await;
let device_name = tranquil_signal::DeviceName::new("tranquil-pds".to_string())
.map_err(|e| ApiError::InternalError(Some(format!("invalid device name: {e}"))))?;
let signal_store = state
.signal_store_provider
.as_ref()
.ok_or_else(|| ApiError::InternalError(Some("Signal store not configured".into())))?;
let link_result = signal_store
.link_signal_device(
device_name,
state.shutdown.clone(),
link_cancel,
slot.linking_flag(),
)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Signal linking failed: {e}"))))?;
let qr_base64 = url_to_qr_png_base64(link_result.url.as_str())
.map_err(|e| ApiError::InternalError(Some(format!("QR generation failed: {e}"))))?;
let slot_for_task = slot.clone();
let shutdown = state.shutdown.clone();
tokio::spawn(async move {
let result = tokio::select! {
biased;
_ = shutdown.cancelled() => {
tracing::info!("server shutting down, aborting signal linking");
return;
}
r = link_result.completion => r,
};
match result {
Ok(Ok(client)) => {
if slot_for_task.complete_link(generation, client).await {
tracing::info!("signal device linked");
} else {
tracing::warn!(
"discarding completed signal link, generation mismatch or already linked"
);
}
}
Ok(Err(e)) => {
tracing::error!(error = %e, "Signal device linking failed");
}
Err(_) => {
tracing::error!("Signal linking task dropped without completing");
}
}
});
Ok(Json(SignalLinkOutput { qr_base64 }))
}
pub async fn unlink_signal_device(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<serde_json::Value>, ApiError> {
let slot = state
.signal_sender
.as_ref()
.ok_or_else(|| ApiError::InvalidRequest("Signal is not enabled".into()))?;
let signal_store = state
.signal_store_provider
.as_ref()
.ok_or_else(|| ApiError::InternalError(Some("Signal store not configured".into())))?;
signal_store
.clear_signal_data()
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to clear signal data: {e}"))))?;
slot.unlink().await;
Ok(Json(serde_json::json!({})))
}
const QR_MODULE_SCALE: u32 = 8;
const QR_QUIET_ZONE_MODULES: u32 = 4;
fn url_to_qr_png_base64(url: &str) -> Result<String, String> {
let qr = qrcodegen::QrCode::encode_text(url, qrcodegen::QrCodeEcc::Medium)
.map_err(|e| format!("QR encode failed: {e:?}"))?;
let size = u32::try_from(qr.size()).map_err(|_| "QR size is negative".to_string())?;
let img_size = size
.checked_add(
QR_QUIET_ZONE_MODULES
.checked_mul(2)
.ok_or("border overflow")?,
)
.ok_or("image size overflow")?
.checked_mul(QR_MODULE_SCALE)
.ok_or("scaled size overflow")?;
let img: ImageBuffer<Luma<u8>, Vec<u8>> = ImageBuffer::from_fn(img_size, img_size, |x, y| {
let module_x = x / QR_MODULE_SCALE;
let module_y = y / QR_MODULE_SCALE;
match (
module_x.checked_sub(QR_QUIET_ZONE_MODULES),
module_y.checked_sub(QR_QUIET_ZONE_MODULES),
) {
(Some(mx), Some(my)) if mx < size && my < size => {
if qr.get_module(mx as i32, my as i32) {
Luma([0u8])
} else {
Luma([255u8])
}
}
_ => Luma([255u8]),
}
});
let mut png_bytes = Vec::new();
let mut cursor = std::io::Cursor::new(&mut png_bytes);
img.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|e| format!("PNG encode failed: {e}"))?;
Ok(STANDARD.encode(&png_bytes))
}
+30 -17
View File
@@ -45,7 +45,7 @@ pub async fn get_subject_status(
let did: Did = did_str
.parse()
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state.user_repo.get_status_by_did(&did).await {
match state.repos.user.get_status_by_did(&did).await {
Ok(Some(status)) => {
let deactivated = status.deactivated_at.map(|_| StatusAttr {
applied: true,
@@ -77,7 +77,7 @@ pub async fn get_subject_status(
let cid: CidLink = uri_str
.parse()
.map_err(|_| ApiError::InvalidRequest("Invalid CID format".into()))?;
match state.repo_repo.get_record_by_cid(&cid).await {
match state.repos.repo.get_record_by_cid(&cid).await {
Ok(Some(record)) => {
let takedown = record.takedown_ref.as_ref().map(|r| StatusAttr {
applied: true,
@@ -109,7 +109,7 @@ pub async fn get_subject_status(
let did = params.did.as_ref().ok_or_else(|| {
ApiError::InvalidRequest("Must provide a did to request blob state".into())
})?;
match state.blob_repo.get_blob_with_takedown(&blob_cid).await {
match state.repos.blob.get_blob_with_takedown(&blob_cid).await {
Ok(Some(blob)) => {
let takedown = blob.takedown_ref.as_ref().map(|r| StatusAttr {
applied: true,
@@ -172,7 +172,8 @@ pub async fn update_subject_status(
None
};
state
.user_repo
.repos
.user
.set_user_takedown(&did, takedown_ref)
.await
.map_err(|e| {
@@ -182,9 +183,9 @@ pub async fn update_subject_status(
}
if let Some(deactivated) = &input.deactivated {
let result = if deactivated.applied {
state.user_repo.deactivate_account(&did, None).await
state.repos.user.deactivate_account(&did, None).await
} else {
state.user_repo.activate_account(&did).await
state.repos.user.activate_account(&did).await
};
result.map_err(|e| {
error!(
@@ -194,11 +195,22 @@ pub async fn update_subject_status(
ApiError::InternalError(Some("Failed to update deactivation status".into()))
})?;
}
let takedown_update = input.takedown.as_ref().map(|t| t.applied);
let takedown_ref = input.takedown.as_ref().and_then(|t| t.r#ref.as_deref());
let deactivated_update = input.deactivated.as_ref().map(|d| d.applied);
if (takedown_update.is_some() || deactivated_update.is_some())
&& let Err(e) = state
.repos
.repo
.update_repo_status(&did, takedown_update, takedown_ref, deactivated_update)
.await
{
warn!("failed to sync status to repo backend: {e:?}");
}
if let Some(takedown) = &input.takedown {
let status = if takedown.applied {
tranquil_db_traits::AccountStatus::Takendown
} else {
tranquil_db_traits::AccountStatus::Active
let status = match takedown.applied {
true => tranquil_db_traits::AccountStatus::Takendown,
false => tranquil_db_traits::AccountStatus::Active,
};
if let Err(e) =
tranquil_pds::repo_ops::sequence_account_event(&state, &did, status).await
@@ -207,10 +219,9 @@ pub async fn update_subject_status(
}
}
if let Some(deactivated) = &input.deactivated {
let status = if deactivated.applied {
tranquil_db_traits::AccountStatus::Deactivated
} else {
tranquil_db_traits::AccountStatus::Active
let status = match deactivated.applied {
true => tranquil_db_traits::AccountStatus::Deactivated,
false => tranquil_db_traits::AccountStatus::Active,
};
if let Err(e) =
tranquil_pds::repo_ops::sequence_account_event(&state, &did, status).await
@@ -218,7 +229,7 @@ pub async fn update_subject_status(
warn!("Failed to sequence account event for deactivation: {}", e);
}
}
if let Ok(Some(handle)) = state.user_repo.get_handle_by_did(&did).await {
if let Ok(Some(handle)) = state.repos.user.get_handle_by_did(&did).await {
let _ = state
.cache
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
@@ -249,7 +260,8 @@ pub async fn update_subject_status(
None
};
state
.repo_repo
.repos
.repo
.set_record_takedown(&cid, takedown_ref)
.await
.map_err(|e| {
@@ -282,7 +294,8 @@ pub async fn update_subject_status(
None
};
state
.blob_repo
.repos
.blob
.update_blob_takedown(&cid, takedown_ref)
.await
.map_err(|e| {
+3 -3
View File
@@ -68,8 +68,8 @@ async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option
let http_uri = "/";
let auth_user = match validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.oauth.as_ref(),
&extracted.token,
extracted.scheme,
dpop_proof,
@@ -89,7 +89,7 @@ async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option
}
};
match state.user_repo.get_by_did(&auth_user.did).await {
match state.repos.user.get_by_did(&auth_user.did).await {
Ok(Some(user)) => {
tracing::debug!(created_at = ?user.created_at, "age assurance: got user");
Some(user.created_at.to_rfc3339())
+28 -5
View File
@@ -1,4 +1,4 @@
use bcrypt::DEFAULT_COST;
use bcrypt::{DEFAULT_COST, hash};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use tracing::error;
@@ -231,10 +231,19 @@ pub async fn verify_credential(
app_passwords
.into_iter()
.find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false))
.map(|app| CredentialMatch::AppPassword {
name: app.name,
scopes: app.scopes,
controller_did: app.created_by_controller_did,
.map(|app| {
let scopes = app.scopes.unwrap_or_else(|| {
if app.privilege.is_privileged() {
"transition:generic transition:chat.bsky".to_string()
} else {
"transition:generic".to_string()
}
});
CredentialMatch::AppPassword {
name: app.name,
scopes: Some(scopes),
controller_did: app.created_by_controller_did,
}
})
}
@@ -245,6 +254,20 @@ pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> {
})
}
pub async fn hash_password_async(password: &str) -> Result<String, ApiError> {
let password = password.to_string();
tokio::task::spawn_blocking(move || hash(password, DEFAULT_COST))
.await
.map_err(|e| {
error!("Failed to spawn blocking task: {:?}", e);
ApiError::InternalError(None)
})?
.map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})
}
pub fn validate_token_hash(
expires_at: Option<DateTime<Utc>>,
stored_hash: &str,
+38 -21
View File
@@ -24,7 +24,8 @@ pub async fn list_controllers(
auth: Auth<Active>,
) -> Result<Json<ControllersOutput<Vec<tranquil_db_traits::ControllerInfo>>>, ApiError> {
let controllers = state
.delegation_repo
.repos
.delegation
.get_delegations_for_account(&auth.did)
.await
.map_err(|e| {
@@ -37,8 +38,9 @@ pub async fn list_controllers(
async move {
if c.handle.is_none() {
c.handle = did_resolver
.resolve_did_document(c.did.as_str())
.fetch_did_document(c.did.as_str())
.await
.ok()
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc))
.map(Into::into);
}
@@ -64,7 +66,7 @@ pub async fn add_controller(
) -> Result<Json<SuccessResponse>, ApiError> {
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
.await
.ok_or(ApiError::ControllerNotFound)?;
.map_err(|_| ApiError::ControllerNotFound)?;
if !resolved.is_local
&& let Some(ref pds_url) = resolved.pds_url
@@ -99,7 +101,8 @@ pub async fn add_controller(
if resolved.is_local
&& state
.delegation_repo
.repos
.delegation
.is_delegated_account(&input.controller_did)
.await
.unwrap_or(false)
@@ -110,7 +113,8 @@ pub async fn add_controller(
}
match state
.delegation_repo
.repos
.delegation
.create_delegation(
can_add.did(),
&input.controller_did,
@@ -121,7 +125,8 @@ pub async fn add_controller(
{
Ok(_) => {
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
can_add.did(),
can_add.did(),
@@ -158,13 +163,15 @@ pub async fn remove_controller(
Json(input): Json<RemoveControllerInput>,
) -> Result<Json<SuccessResponse>, ApiError> {
match state
.delegation_repo
.repos
.delegation
.revoke_delegation(&auth.did, &input.controller_did, &auth.did)
.await
{
Ok(true) => {
let revoked_app_passwords = state
.session_repo
.repos
.session
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
.await
.unwrap_or(0)
@@ -172,13 +179,15 @@ pub async fn remove_controller(
.unwrap_or(0usize);
let revoked_oauth_tokens = state
.oauth_repo
.repos
.oauth
.revoke_tokens_for_controller(&auth.did, &input.controller_did)
.await
.unwrap_or(0);
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&auth.did,
&auth.did,
@@ -217,13 +226,15 @@ pub async fn update_controller_scopes(
Json(input): Json<UpdateControllerScopesInput>,
) -> Result<Json<SuccessResponse>, ApiError> {
match state
.delegation_repo
.repos
.delegation
.update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes)
.await
{
Ok(true) => {
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&auth.did,
&auth.did,
@@ -254,7 +265,8 @@ pub async fn list_controlled_accounts(
auth: Auth<Active>,
) -> Result<Json<AccountsOutput<Vec<tranquil_db_traits::DelegatedAccountInfo>>>, ApiError> {
let accounts = state
.delegation_repo
.repos
.delegation
.get_accounts_controlled_by(&auth.did)
.await
.map_err(|e| {
@@ -286,7 +298,8 @@ pub async fn get_audit_log(
let offset = params.offset.max(0);
let entries = state
.delegation_repo
.repos
.delegation
.get_audit_log_for_account(&auth.did, limit, offset)
.await
.map_err(|e| {
@@ -295,7 +308,8 @@ pub async fn get_audit_log(
})?;
let total = state
.delegation_repo
.repos
.delegation
.count_audit_log_entries(&auth.did)
.await
.unwrap_or_default();
@@ -349,7 +363,7 @@ pub async fn create_delegated_account(
}
let validated_invite_code = if let Some(ref code) = input.invite_code {
match state.infra_repo.validate_invite_code(code).await {
match state.repos.infra.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return Err(ApiError::InvalidInviteCode),
}
@@ -387,7 +401,8 @@ pub async fn create_delegated_account(
};
let user_id = match state
.user_repo
.repos
.user
.create_delegated_account(&create_input)
.await
{
@@ -406,7 +421,8 @@ pub async fn create_delegated_account(
if let Some(validated) = validated_invite_code
&& let Err(e) = state
.infra_repo
.repos
.infra
.record_invite_code_use(&validated, user_id)
.await
{
@@ -423,7 +439,8 @@ pub async fn create_delegated_account(
.await;
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&did,
&auth.did,
@@ -461,7 +478,7 @@ pub async fn resolve_controller(
} else {
let local_handle: Option<Handle> = identifier.parse().ok();
let local_user = match local_handle {
Some(ref h) => state.user_repo.get_by_handle(h).await.ok().flatten(),
Some(ref h) => state.repos.user.get_by_handle(h).await.ok().flatten(),
None => None,
};
match local_user {
@@ -476,7 +493,7 @@ pub async fn resolve_controller(
let resolved = tranquil_pds::delegation::resolve_identity(&state, &did)
.await
.ok_or(ApiError::ControllerNotFound)?;
.map_err(|_| ApiError::ControllerNotFound)?;
Ok(Json(resolved))
}
+4 -3
View File
@@ -169,7 +169,8 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
);
match state
.user_repo
.repos
.user
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref())
.await
{
@@ -180,8 +181,8 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
"Verified Discord user and stored user ID"
);
if let Err(e) = comms_repo::enqueue_channel_verified(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
tranquil_db_traits::CommsChannel::Discord,
&discord_user_id,
+28 -25
View File
@@ -6,7 +6,6 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use bcrypt::{DEFAULT_COST, hash};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{debug, error, info};
@@ -16,7 +15,6 @@ use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Handle, PlainPassword};
use tranquil_pds::validation::validate_password;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountInput {
@@ -68,14 +66,16 @@ async fn try_reactivate_migration(
new_email: email.clone(),
};
match state
.user_repo
.repos
.user
.reactivate_migration_account(&reactivate_input)
.await
{
Ok(reactivated) => {
info!(did = %did, old_handle = %reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
let secret_key_bytes = match state
.user_repo
.repos
.user
.get_user_key_by_id(reactivated.user_id)
.await
{
@@ -130,7 +130,7 @@ async fn try_reactivate_migration(
controller_did: None,
app_password_name: None,
};
if let Err(e) = state.session_repo.create_session(&session_data).await {
if let Err(e) = state.repos.session.create_session(&session_data).await {
error!("Error creating session: {:?}", e);
return Some(ApiError::InternalError(None).into_response());
}
@@ -154,7 +154,12 @@ async fn try_reactivate_migration(
Json(CreateAccountOutput {
handle: handle.to_string().into(),
did: did_typed.clone(),
did_doc: state.did_resolver.resolve_did_document(did).await,
did_doc: state
.did_resolver
.fetch_did_document(did)
.await
.ok()
.map(|f| (*f).clone()),
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
verification_required,
@@ -395,7 +400,8 @@ pub async fn create_account(
Err(_) => return ApiError::InvalidHandle(None).into_response(),
};
let handle_available = match state
.user_repo
.repos
.user
.check_handle_available_for_new_account(&handle_typed)
.await
{
@@ -410,7 +416,7 @@ pub async fn create_account(
}
let is_bootstrap = state.bootstrap_invite_code.is_some()
&& state.user_repo.count_users().await.unwrap_or(1) == 0;
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
if is_bootstrap {
match input.invite_code.as_deref() {
@@ -431,7 +437,7 @@ pub async fn create_account(
if let Some(code) = &input.invite_code
&& !code.trim().is_empty()
{
let valid = match state.user_repo.check_and_consume_invite_code(code).await {
let valid = match state.repos.user.check_and_consume_invite_code(code).await {
Ok(v) => v,
Err(e) => {
error!("Error checking invite code: {:?}", e);
@@ -448,19 +454,10 @@ pub async fn create_account(
return ApiError::InvalidRequest(e.to_string()).into_response();
}
let password_clone = input.password.clone();
let password_hash =
match tokio::task::spawn_blocking(move || hash(&password_clone, DEFAULT_COST)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => {
error!("Error hashing password: {:?}", e);
return ApiError::InternalError(None).into_response();
}
Err(e) => {
error!("Failed to spawn blocking task: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let password_hash = match crate::common::hash_password_async(&input.password).await {
Ok(h) => h,
Err(e) => return e.into_response(),
};
let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration || is_did_web_byod {
Some(chrono::Utc::now())
@@ -513,6 +510,7 @@ pub async fn create_account(
telegram_username: comms.telegram,
signal_username: comms.signal,
deactivated_at,
inbound_migration: is_migration || is_did_web_byod,
encrypted_key_bytes: repo.encrypted_key_bytes,
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
reserved_key_id,
@@ -527,7 +525,12 @@ pub async fn create_account(
birthdate_pref,
};
let create_result = match state.user_repo.create_password_account(&create_input).await {
let create_result = match state
.repos
.user
.create_password_account(&create_input)
.await
{
Ok(r) => r,
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
return ApiError::HandleNotAvailable(None).into_response();
@@ -590,7 +593,7 @@ pub async fn create_account(
Err(e) => return e.into_response(),
};
let did_doc = state.did_resolver.resolve_did_document(&did).await;
let did_doc = state.did_resolver.fetch_did_document(&did).await.ok();
if is_migration {
info!(
@@ -604,7 +607,7 @@ pub async fn create_account(
Json(CreateAccountOutput {
handle: handle.clone().into(),
did: did_for_commit,
did_doc,
did_doc: did_doc.map(|f| (*f).clone()),
access_jwt: session.access_jwt,
refresh_jwt: session.refresh_jwt,
verification_required: !is_migration,
+20 -12
View File
@@ -54,7 +54,7 @@ pub async fn resolve_handle(
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
}
};
let user = state.user_repo.get_by_handle(&handle).await;
let user = state.repos.user.get_by_handle(&handle).await;
match user {
Ok(Some(row)) => {
let _ = state
@@ -165,7 +165,8 @@ async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) ->
Err(_) => return ApiError::InvalidRequest("Invalid DID format".into()).into_response(),
};
let user = match state
.user_repo
.repos
.user
.get_user_for_did_doc_build(&expected_did_typed)
.await
{
@@ -182,7 +183,8 @@ async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) ->
let did = expected_did;
let overrides = state
.user_repo
.repos
.user
.get_did_web_overrides(user_id)
.await
.ok()
@@ -218,7 +220,8 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
}
};
let user = match state
.user_repo
.repos
.user
.get_did_web_info_by_handle(&current_handle_typed)
.await
{
@@ -246,7 +249,8 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
}
let overrides = state
.user_repo
.repos
.user
.get_did_web_overrides(user_id)
.await
.ok()
@@ -295,7 +299,7 @@ async fn build_override_or_key_verification_methods(
.collect());
}
let key_info = match state.user_repo.get_user_key_by_id(user_id).await {
let key_info = match state.repos.user.get_user_key_by_id(user_id).await {
Ok(Some(k)) => k,
_ => return Err(ApiError::InternalError(None).into_response()),
};
@@ -468,7 +472,8 @@ pub async fn get_recommended_did_credentials(
auth: Auth<NotTakendown>,
) -> Result<Json<GetRecommendedDidCredentialsOutput>, ApiError> {
let handle = state
.user_repo
.repos
.user
.get_handle_by_did(&auth.did)
.await
.log_db_err("fetching handle for DID credentials")?
@@ -539,7 +544,8 @@ pub async fn update_handle(
)
.await?;
let user_row = state
.user_repo
.repos
.user
.get_id_and_handle_by_did(&did)
.await
.log_db_err("fetching user for handle update")?
@@ -661,7 +667,8 @@ pub async fn update_handle(
.parse()
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?;
let handle_exists = state
.user_repo
.repos
.user
.check_handle_exists(&handle_typed, user_id)
.await
.log_db_err("checking handle existence")?;
@@ -669,7 +676,8 @@ pub async fn update_handle(
return Err(ApiError::HandleTaken);
}
state
.user_repo
.repos
.user
.update_handle(user_id, &handle_typed)
.await
.map_err(|e| {
@@ -706,7 +714,7 @@ pub async fn update_plc_handle(
if !did.as_str().starts_with("did:plc:") {
return Ok(());
}
let user_row = match state.user_repo.get_user_with_key_by_did(did).await? {
let user_row = match state.repos.user.get_user_with_key_by_did(did).await? {
Some(r) => r,
None => return Ok(()),
};
@@ -733,7 +741,7 @@ pub async fn well_known_atproto_did(State(state): State<AppState>, headers: Head
Ok(h) => h,
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid handle format").into_response(),
};
let user = state.user_repo.get_by_handle(&handle).await;
let user = state.repos.user.get_by_handle(&handle).await;
match user {
Ok(Some(row)) => row.did.to_string().into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Handle not found").into_response(),
@@ -20,25 +20,27 @@ pub async fn request_plc_operation_signature(
tranquil_pds::oauth::scopes::IdentityAttr::Wildcard,
)?;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(&auth.did)
.await
.log_db_err("fetching user id")?
.ok_or(ApiError::AccountNotFound)?;
let _ = state.infra_repo.delete_plc_tokens_for_user(user_id).await;
let _ = state.repos.infra.delete_plc_tokens_for_user(user_id).await;
let plc_token = generate_plc_token();
let expires_at = Utc::now() + Duration::minutes(10);
state
.infra_repo
.repos
.infra
.insert_plc_token(user_id, &plc_token, expires_at)
.await
.log_db_err("creating PLC token")?;
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_plc_operation(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
&plc_token,
hostname,
+8 -5
View File
@@ -55,25 +55,28 @@ pub async fn sign_plc_operation(
})?;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(did)
.await
.log_db_err("fetching user id")?
.ok_or(ApiError::AccountNotFound)?;
let token_expiry = state
.infra_repo
.repos
.infra
.get_plc_token_expiry(user_id, token)
.await
.log_db_err("fetching PLC token expiry")?
.ok_or_else(|| ApiError::InvalidToken(Some("Invalid or expired token".into())))?;
if Utc::now() > token_expiry {
let _ = state.infra_repo.delete_plc_token(user_id, token).await;
let _ = state.repos.infra.delete_plc_token(user_id, token).await;
return Err(ApiError::ExpiredToken(Some("Token has expired".into())));
}
let key_row = state
.user_repo
.repos
.user
.get_user_key_by_id(user_id)
.await
.log_db_err("fetching user key")?
@@ -136,7 +139,7 @@ pub async fn sign_plc_operation(
ApiError::InternalError(None)
})?;
let _ = state.infra_repo.delete_plc_token(user_id, token).await;
let _ = state.repos.infra.delete_plc_token(user_id, token).await;
info!("Signed PLC operation for user {}", did);
Ok(Json(SignPlcOperationOutput {
operation: signed_op,
@@ -38,14 +38,16 @@ pub async fn submit_plc_operation(
let hostname = &tranquil_config::get().server.hostname;
let public_url = format!("https://{}", hostname);
let user = state
.user_repo
.repos
.user
.get_id_and_handle_by_did(did)
.await
.log_db_err("fetching user")?
.ok_or(ApiError::AccountNotFound)?;
let key_row = state
.user_repo
.repos
.user
.get_user_key_by_id(user.id)
.await
.log_db_err("fetching user key")?
@@ -128,12 +130,13 @@ pub async fn submit_plc_operation(
.map_err(ApiError::from)?;
match state
.repo_repo
.repos
.repo
.insert_identity_event(did, Some(&user.handle))
.await
{
Ok(seq) => {
if let Err(e) = state.repo_repo.notify_update(seq).await {
if let Err(e) = state.repos.repo.notify_update(seq).await {
warn!("Failed to notify identity event: {:?}", e);
}
}
@@ -153,7 +156,7 @@ pub async fn submit_plc_operation(
.cache
.delete(&tranquil_pds::cache_keys::plc_data_key(did))
.await;
if state.did_resolver.refresh_did(did).await.is_none() {
if state.did_resolver.refresh_did(did).await.is_err() {
warn!(did = %did, "Failed to refresh DID cache after PLC update");
}
info!(did = %did, "PLC operation submitted successfully");
@@ -136,7 +136,8 @@ pub async fn resolve_signing_key(
match signing_key_did {
Some(key_did) => {
let key = state
.infra_repo
.repos
.infra
.get_reserved_signing_key(key_did)
.await
.map_err(|e| {
@@ -295,7 +296,8 @@ pub async fn create_and_store_session(
app_password_name: None,
};
state
.session_repo
.repos
.session
.create_session(&session_data)
.await
.map_err(|e| {
@@ -320,8 +322,8 @@ pub async fn enqueue_signup_verification(
let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
channel,
recipient,
@@ -346,8 +348,8 @@ pub async fn enqueue_migration_verification(
let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
channel,
recipient,
+7
View File
@@ -330,6 +330,13 @@ pub fn api_routes() -> axum::Router<AppState> {
get(admin::get_invite_codes),
)
.route("/_admin.getServerStats", get(admin::get_server_stats))
.route("/_admin.setAdminStatus", post(admin::set_admin_status))
.route("/_admin.getSignalStatus", get(admin::get_signal_status))
.route("/_admin.linkSignalDevice", post(admin::link_signal_device))
.route(
"/_admin.unlinkSignalDevice",
post(admin::unlink_signal_device),
)
.route("/_server.getConfig", get(admin::get_server_config))
.route(
"/_admin.updateServerConfig",
+4 -3
View File
@@ -107,7 +107,7 @@ async fn proxy_to_report_service(
let key_bytes = match &auth_user.key_bytes {
Some(kb) => kb.clone(),
None => match state.user_repo.get_with_key_by_did(&auth_user.did).await {
None => match state.repos.user.get_with_key_by_did(&auth_user.did).await {
Ok(Some(user_with_key)) => {
match tranquil_pds::config::decrypt_key(
&user_with_key.key_bytes,
@@ -138,7 +138,7 @@ async fn proxy_to_report_service(
let service_token = match tranquil_pds::auth::create_service_token(
&auth_user.did,
service_did,
"com.atproto.moderation.createReport",
Some("com.atproto.moderation.createReport"),
&key_bytes,
) {
Ok(t) => t,
@@ -226,7 +226,8 @@ async fn create_report_locally(
let subject_json = json!(input.subject);
if let Err(e) = state
.infra_repo
.repos
.infra
.insert_report(
report_id,
input.reason_type.as_str(),
+32 -17
View File
@@ -26,7 +26,8 @@ pub async fn get_notification_prefs(
auth: Auth<Active>,
) -> Result<Json<NotificationPrefsOutput>, ApiError> {
let prefs = state
.user_repo
.repos
.user
.get_notification_prefs(&auth.did)
.await
.log_db_err("get notification prefs")?
@@ -65,14 +66,16 @@ pub async fn get_notification_history(
auth: Auth<Active>,
) -> Result<Json<GetNotificationHistoryOutput>, ApiError> {
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(&auth.did)
.await
.log_db_err("get user id by did")?
.ok_or(ApiError::AccountNotFound)?;
let rows = state
.infra_repo
.repos
.infra
.get_notification_history(user_id, 50)
.await
.log_db_err("get notification history")?;
@@ -146,7 +149,7 @@ pub async fn request_channel_verification(
let hostname = &tranquil_config::get().server.hostname;
let handle_str = handle.unwrap_or("user");
tranquil_pds::comms::comms_repo::enqueue_email_update(
state.infra_repo.as_ref(),
state.repos.infra.as_ref(),
user_id,
identifier,
handle_str,
@@ -165,7 +168,8 @@ pub async fn request_channel_verification(
hostname, encoded_token, encoded_identifier
);
let prefs = state
.user_repo
.repos
.user
.get_comms_prefs(user_id)
.await
.ok()
@@ -185,7 +189,8 @@ pub async fn request_channel_verification(
);
let recipient = match channel {
CommsChannel::Telegram => state
.user_repo
.repos
.user
.get_telegram_chat_id(user_id)
.await
.ok()
@@ -195,7 +200,8 @@ pub async fn request_channel_verification(
_ => identifier.to_string(),
};
state
.infra_repo
.repos
.infra
.enqueue_comms(
Some(user_id),
channel,
@@ -238,17 +244,20 @@ async fn process_messaging_channel_update(
}
match channel {
CommsChannel::Discord => state
.user_repo
.repos
.user
.clear_discord(user_id)
.await
.log_db_err("clear discord")?,
CommsChannel::Telegram => state
.user_repo
.repos
.user
.clear_telegram(user_id)
.await
.log_db_err("clear telegram")?,
CommsChannel::Signal => state
.user_repo
.repos
.user
.clear_signal(user_id)
.await
.log_db_err("clear signal")?,
@@ -273,7 +282,7 @@ async fn process_messaging_channel_update(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
),
CommsChannel::Signal => ApiError::InvalidRequest(
"Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)".into(),
"Invalid Signal username. Must be a 3-32 character nickname, a dot, then a 2-20 digit discriminator".into(),
),
CommsChannel::Email => ApiError::InvalidEmail,
});
@@ -281,17 +290,20 @@ async fn process_messaging_channel_update(
match channel {
CommsChannel::Discord => state
.user_repo
.repos
.user
.set_unverified_discord(user_id, &clean)
.await
.log_db_err("set unverified discord")?,
CommsChannel::Telegram => state
.user_repo
.repos
.user
.set_unverified_telegram(user_id, &clean)
.await
.log_db_err("set unverified telegram")?,
CommsChannel::Signal => state
.user_repo
.repos
.user
.set_unverified_signal(user_id, &clean)
.await
.log_db_err("set unverified signal")?,
@@ -313,7 +325,8 @@ pub async fn update_notification_prefs(
Json(input): Json<UpdateNotificationPrefsInput>,
) -> Result<Json<UpdateNotificationPrefsOutput>, ApiError> {
let user_row = state
.user_repo
.repos
.user
.get_id_handle_email_by_did(&auth.did)
.await
.log_db_err("get user by did")?
@@ -324,7 +337,8 @@ pub async fn update_notification_prefs(
let current_email = user_row.email;
let current_prefs = state
.user_repo
.repos
.user
.get_notification_prefs(&auth.did)
.await
.log_db_err("get notification prefs for update")?
@@ -347,7 +361,8 @@ pub async fn update_notification_prefs(
if input.preferred_channel.is_some() {
state
.user_repo
.repos
.user
.update_preferred_comms_channel(&auth.did, effective_channel)
.await
.log_db_err("update preferred channel")?;
+13 -7
View File
@@ -66,7 +66,8 @@ pub async fn upload_blob(
};
if state
.user_repo
.repos
.user
.is_account_migrated(&did)
.await
.unwrap_or(false)
@@ -78,7 +79,8 @@ pub async fn upload_blob(
get_header_str(&headers, http::header::CONTENT_TYPE).unwrap_or("application/octet-stream");
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(&did)
.await
.log_db_err("fetching user id for blob upload")?
@@ -143,7 +145,8 @@ pub async fn upload_blob(
);
match state
.blob_repo
.repos
.blob
.insert_blob(
&cid_link,
&mime_type,
@@ -163,7 +166,7 @@ pub async fn upload_blob(
if let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await {
let _ = state.blob_store.delete(&temp_key).await;
if let Err(db_err) = state.blob_repo.delete_blob_by_cid(&cid_link).await {
if let Err(db_err) = state.repos.blob.delete_blob_by_cid(&cid_link).await {
error!(
"Failed to clean up orphaned blob record after copy failure: {:?}",
db_err
@@ -177,7 +180,8 @@ pub async fn upload_blob(
if let Some(ref controller) = controller_did
&& let Err(e) = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&did,
controller,
@@ -236,7 +240,8 @@ pub async fn list_missing_blobs(
) -> Result<Json<ListMissingBlobsOutput>, ApiError> {
let did = &auth.did;
let user = state
.user_repo
.repos
.user
.get_by_did(did)
.await
.log_db_err("fetching user")?
@@ -245,7 +250,8 @@ pub async fn list_missing_blobs(
let limit = params.limit.unwrap_or(500).clamp(1, 1000);
let cursor = params.cursor.as_deref();
let missing = state
.blob_repo
.repos
.blob
.list_missing_blobs(user.id, cursor, limit + 1)
.await
.log_db_err("fetching missing blobs")?;
+42 -16
View File
@@ -34,7 +34,8 @@ pub async fn import_repo(
}
let did = &auth.did;
let user = state
.user_repo
.repos
.user
.get_by_did(did)
.await
.log_db_err("fetching user")?
@@ -44,7 +45,8 @@ pub async fn import_repo(
}
let user_id = user.id;
let expected_root_cid = state
.repo_repo
.repos
.repo
.get_repo_root_cid_by_user_id(user_id)
.await
.map_err(|e| {
@@ -104,7 +106,7 @@ pub async fn import_repo(
.map(|c| c.import.skip_verification)
.unwrap_or(false)
});
let is_migration = user.deactivated_at.is_some();
let is_migration = user.inbound_migration && user.deactivated_at.is_some();
if skip_verification {
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
} else if is_migration {
@@ -193,8 +195,18 @@ pub async fn import_repo(
}
let max_blocks = tranquil_config::get().import.max_blocks as usize;
let _write_lock = state.repo_write_locks.lock(user_id).await;
state
.block_store
.put_many(blocks.clone())
.await
.map_err(|e| {
error!("Failed to store import blocks: {:?}", e);
ApiError::InternalError(None)
})?;
match apply_import(
&state.repo_repo,
&state.repos.repo,
user_id,
root,
blocks.clone(),
@@ -232,7 +244,8 @@ pub async fn import_repo(
blob_refs.into_iter().unzip();
match state
.blob_repo
.repos
.blob
.insert_record_blobs(user_id, &record_uris, &blob_cids)
.await
{
@@ -248,7 +261,8 @@ pub async fn import_repo(
}
}
let key_row = state
.user_repo
.repos
.user
.get_user_with_key_by_did(did)
.await
.map_err(|e| {
@@ -289,7 +303,8 @@ pub async fn import_repo(
})?;
let new_root_cid_link = CidLink::from(&new_root_cid);
state
.repo_repo
.repos
.repo
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
.await
.map_err(|e| {
@@ -299,7 +314,8 @@ pub async fn import_repo(
let mut all_block_cids: Vec<Vec<u8>> = blocks.keys().map(|c| c.to_bytes()).collect();
all_block_cids.push(new_root_cid.to_bytes());
state
.repo_repo
.repos
.repo
.insert_user_blocks(user_id, &all_block_cids, &new_rev_str)
.await
.map_err(|e| {
@@ -312,7 +328,8 @@ pub async fn import_repo(
new_root_str, new_rev_str
);
if !is_migration
&& let Err(e) = sequence_import_event(&state, did, &new_root_cid_link).await
&& let Err(e) =
sequence_import_event(&state, did, &new_root_cid_link, &commit_bytes).await
{
warn!("Failed to sequence import event: {:?}", e);
}
@@ -322,7 +339,8 @@ pub async fn import_repo(
"birthDate": "1998-05-06T00:00:00.000Z"
});
if let Err(e) = state
.infra_repo
.repos
.infra
.insert_account_preference_if_not_exists(
user_id,
"app.bsky.actor.defs#personalDetailsPref",
@@ -378,20 +396,28 @@ async fn sequence_import_event(
state: &AppState,
did: &Did,
commit_cid: &CidLink,
) -> Result<(), tranquil_db::DbError> {
let data = tranquil_db::CommitEventData {
commit_bytes: &[u8],
) -> Result<(), tranquil_db_traits::DbError> {
let commit_cid_parsed = commit_cid
.to_cid()
.expect("CidLink invariant: validated at construction");
let inline_commit = tranquil_db_traits::EventBlockInline {
cid_bytes: commit_cid_parsed.to_bytes(),
data: commit_bytes.to_vec(),
};
let data = tranquil_db_traits::CommitEventData {
did: did.clone(),
event_type: tranquil_db::RepoEventType::Commit,
event_type: tranquil_db_traits::RepoEventType::Commit,
commit_cid: Some(commit_cid.clone()),
prev_cid: None,
ops: Some(serde_json::json!([])),
blobs: Some(vec![]),
blocks_cids: Some(vec![]),
blocks: Some(vec![inline_commit]),
prev_data_cid: None,
rev: None,
};
let seq = state.repo_repo.insert_commit_event(&data).await?;
state.repo_repo.notify_update(seq).await?;
let seq = state.repos.repo.insert_commit_event(&data).await?;
state.repos.repo.notify_update(seq).await?;
Ok(())
}
+3 -2
View File
@@ -18,12 +18,13 @@ pub async fn describe_repo(
State(state): State<AppState>,
Query(input): Query<DescribeRepoInput>,
) -> Response {
let resolved = match common::resolve_repo(state.user_repo.as_ref(), &input.repo).await {
let resolved = match common::resolve_repo(state.repos.user.as_ref(), &input.repo).await {
Ok(r) => r,
Err(e) => return e.into_response(),
};
let collections = state
.repo_repo
.repos
.repo
.list_collections(resolved.user_id)
.await
.unwrap_or_default();
+61 -23
View File
@@ -6,14 +6,16 @@ use jacquard_repo::{mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::info;
use tranquil_db_traits::Backlink;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{
Active, Auth, WriteOpKind, require_not_migrated, require_verified_or_delegated,
verify_batch_write_scopes,
};
use tranquil_pds::repo::tracking::TrackingBlockStore;
use tranquil_pds::repo::TrackingBlockStore;
use tranquil_pds::repo_ops::{
FinalizeParams, RecordOp, begin_repo_write, extract_blob_cids, finalize_repo_write,
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
finalize_repo_write,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
@@ -25,8 +27,9 @@ struct WriteAccumulator {
mst: Mst<TrackingBlockStore>,
results: Vec<WriteResult>,
ops: Vec<RecordOp>,
modified_keys: Vec<String>,
all_blob_cids: Vec<String>,
backlinks_to_add: Vec<Backlink>,
backlinks_to_remove: Vec<AtUri>,
}
async fn process_single_write(
@@ -40,8 +43,9 @@ async fn process_single_write(
mst,
mut results,
mut ops,
mut modified_keys,
mut all_blob_cids,
mut backlinks_to_add,
mut backlinks_to_remove,
} = acc;
match write {
@@ -63,8 +67,19 @@ async fn process_single_write(
.await?,
)
};
all_blob_cids.extend(extract_blob_cids(value));
let rkey = rkey.clone().unwrap_or_else(Rkey::generate);
let key = format!("{}/{}", collection, rkey);
if mst
.get(&key)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
.is_some()
{
return Err(ApiError::InvalidRequest(format!(
"Record already exists at {key}"
)));
}
all_blob_cids.extend(extract_blob_cids(value));
let record_ipld = tranquil_pds::util::json_to_ipld(value);
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
@@ -72,13 +87,12 @@ async fn process_single_write(
.put(&record_bytes)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
let key = format!("{}/{}", collection, rkey);
modified_keys.push(key.clone());
let new_mst = mst
.add(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
let uri = AtUri::from_parts(did, collection, &rkey);
backlinks_to_add.extend(extract_backlinks(&uri, value));
results.push(WriteResult::CreateResult {
uri,
cid: record_cid.to_string(),
@@ -87,14 +101,15 @@ async fn process_single_write(
ops.push(RecordOp::Create {
collection: collection.clone(),
rkey: rkey.clone(),
cid: record_cid,
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
});
Ok(WriteAccumulator {
mst: new_mst,
results,
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
})
}
WriteOp::Update {
@@ -115,6 +130,16 @@ async fn process_single_write(
.await?,
)
};
let key = format!("{}/{}", collection, rkey);
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| {
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
})?
.ok_or_else(|| {
ApiError::InvalidRequest("Update target record does not exist".into())
})?;
all_blob_cids.extend(extract_blob_cids(value));
let record_ipld = tranquil_pds::util::json_to_ipld(value);
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
@@ -123,14 +148,13 @@ async fn process_single_write(
.put(&record_bytes)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
let key = format!("{}/{}", collection, rkey);
modified_keys.push(key.clone());
let prev_record_cid = mst.get(&key).await.ok().flatten();
let new_mst = mst
.update(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
let uri = AtUri::from_parts(did, collection, rkey);
backlinks_to_remove.push(uri.clone());
backlinks_to_add.extend(extract_backlinks(&uri, value));
results.push(WriteResult::UpdateResult {
uri,
cid: record_cid.to_string(),
@@ -139,37 +163,47 @@ async fn process_single_write(
ops.push(RecordOp::Update {
collection: collection.clone(),
rkey: rkey.clone(),
cid: record_cid,
prev: prev_record_cid,
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
prev: tranquil_pds::cid_types::RecordCid::from(prev_record_cid),
});
Ok(WriteAccumulator {
mst: new_mst,
results,
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
})
}
WriteOp::Delete { collection, rkey } => {
let key = format!("{}/{}", collection, rkey);
modified_keys.push(key.clone());
let prev_record_cid = mst.get(&key).await.ok().flatten();
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| {
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
})?
.ok_or_else(|| {
ApiError::InvalidRequest("Delete target record does not exist".into())
})?;
let new_mst = mst
.delete(&key)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to delete from MST".into())))?;
backlinks_to_remove.push(AtUri::from_parts(did, collection, rkey));
results.push(WriteResult::DeleteResult {});
ops.push(RecordOp::Delete {
collection: collection.clone(),
rkey: rkey.clone(),
prev: prev_record_cid,
prev: tranquil_pds::cid_types::RecordCid::from(prev_record_cid),
});
Ok(WriteAccumulator {
mst: new_mst,
results,
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
})
}
}
@@ -187,8 +221,9 @@ async fn process_writes(
mst: initial_mst,
results: Vec::new(),
ops: Vec::new(),
modified_keys: Vec::new(),
all_blob_cids: Vec::new(),
backlinks_to_add: Vec::new(),
backlinks_to_remove: Vec::new(),
};
stream::iter(writes.iter().map(Ok::<_, ApiError>))
.try_fold(initial_acc, |acc, write| async move {
@@ -304,7 +339,8 @@ pub async fn apply_writes(
require_verified_or_delegated(&state, batch_proof.user()).await?;
let user_id: uuid::Uuid = state
.user_repo
.repos
.user
.get_id_by_did(&did)
.await
.log_db_err("fetching user for batch write")?
@@ -316,8 +352,9 @@ pub async fn apply_writes(
mst: final_mst,
results,
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
} = process_writes(
&input.writes,
mst,
@@ -370,8 +407,9 @@ pub async fn apply_writes(
controller_did: controller_did.as_ref(),
delegation_detail: write_summary,
ops,
modified_keys: &modified_keys,
blob_cids: &all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
},
)
.await?;
+14 -140
View File
@@ -1,20 +1,16 @@
use crate::repo::record::write::{CommitInfo, prepare_repo_write};
use axum::{Json, extract::State};
use cid::Cid;
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;
use std::sync::Arc;
use tracing::error;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Active, Auth, VerifyScope};
use tranquil_pds::repo::tracking::TrackingBlockStore;
use tranquil_pds::repo_ops::{
CommitError, FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write,
};
use tranquil_pds::cid_types::RecordCid;
use tranquil_pds::repo_ops::{FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
use tranquil_pds::types::{AtIdentifier, AtUri, Nsid, Rkey};
#[derive(Deserialize)]
pub struct DeleteRecordInput {
@@ -59,23 +55,26 @@ pub async fn delete_record(
}
}
let prev_record_cid = mst.get(&key).await.ok().flatten();
if prev_record_cid.is_none() {
let prev_record_cid = mst.get(&key).await.map_err(|e| {
error!("Failed to read prev record from MST: {}", e);
ApiError::InternalError(Some("Failed to read MST".into()))
})?;
let Some(prev_record_cid) = prev_record_cid else {
return Ok(Json(DeleteRecordOutput { commit: None }));
}
};
let new_mst = mst.delete(&key).await.map_err(|e| {
error!("Failed to delete from MST: {:?}", e);
error!("Failed to delete from MST: {}", e);
ApiError::InternalError(Some("Failed to delete from MST".into()))
})?;
let op = RecordOp::Delete {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
prev: prev_record_cid,
prev: RecordCid::from(prev_record_cid),
};
let modified_keys = [key];
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
let commit_result = finalize_repo_write(
&state,
@@ -93,21 +92,13 @@ pub async fn delete_record(
})
}),
ops: vec![op],
modified_keys: &modified_keys,
blob_cids: &[],
backlinks_to_add: vec![],
backlinks_to_remove: vec![deleted_uri],
},
)
.await?;
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
if let Err(e) = state
.backlink_repo
.remove_backlinks_by_uri(&deleted_uri)
.await
{
error!("Failed to remove backlinks for {}: {}", deleted_uri, e);
}
Ok(Json(DeleteRecordOutput {
commit: Some(CommitInfo {
cid: commit_result.commit_cid.to_string(),
@@ -115,120 +106,3 @@ pub async fn delete_record(
}),
}))
}
use uuid::Uuid;
pub async fn delete_record_internal(
state: &AppState,
did: &Did,
user_id: Uuid,
collection: &Nsid,
rkey: &Rkey,
) -> Result<(), CommitError> {
use tranquil_pds::repo_ops::{CommitParams, RecordOp, commit_and_log};
let _write_lock = state.repo_write_locks.lock(user_id).await;
let root_cid_str = state
.repo_repo
.get_repo_root_cid_by_user_id(user_id)
.await
.map_err(|e| CommitError::DatabaseError(e.to_string()))?
.ok_or(CommitError::RepoNotFound)?;
let current_root_cid =
Cid::from_str(root_cid_str.as_str()).map_err(|e| CommitError::InvalidCid(e.to_string()))?;
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = tracking_store
.get(&current_root_cid)
.await
.map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?
.ok_or(CommitError::BlockStoreFailed(
"Commit block not found".into(),
))?;
let commit = Commit::from_cbor(&commit_bytes)
.map_err(|e| CommitError::CommitParseFailed(format!("{:?}", e)))?;
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let key = format!("{}/{}", collection, rkey);
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let Some(prev_cid) = prev_record_cid else {
return Ok(());
};
let new_mst = mst
.delete(&key)
.await
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let new_mst_root = new_mst
.persist()
.await
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let op = RecordOp::Delete {
collection: collection.clone(),
rkey: rkey.clone(),
prev: Some(prev_cid),
};
let mut new_mst_blocks = std::collections::BTreeMap::new();
let mut old_mst_blocks = std::collections::BTreeMap::new();
new_mst
.blocks_for_path(&key, &mut new_mst_blocks)
.await
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
mst.blocks_for_path(&key, &mut old_mst_blocks)
.await
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
.chain(
old_mst_blocks
.keys()
.filter(|cid| !new_mst_blocks.contains_key(*cid))
.copied(),
)
.chain(std::iter::once(prev_cid))
.collect();
let mut relevant_blocks = new_mst_blocks;
relevant_blocks.extend(old_mst_blocks);
let written_cids: Vec<Cid> = tracking_store
.get_all_relevant_cids()
.into_iter()
.chain(relevant_blocks.keys().copied())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let written_cids_str: Vec<String> = written_cids.iter().map(ToString::to_string).collect();
commit_and_log(
state,
CommitParams {
did,
user_id,
current_root_cid: Some(current_root_cid),
prev_data_cid: Some(commit.data),
new_mst_root,
ops: vec![op],
blocks_cids: &written_cids_str,
blobs: &[],
obsolete_cids,
},
)
.await?;
Ok(())
}
+1 -1
View File
@@ -10,7 +10,7 @@ pub use pagination::PaginationDirection;
pub use validation_mode::ValidationMode;
pub use batch::apply_writes;
pub use delete::{DeleteRecordInput, delete_record, delete_record_internal};
pub use delete::{DeleteRecordInput, delete_record};
pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, list_records};
pub use tranquil_pds::repo_ops::*;
pub use write::{
+7 -5
View File
@@ -60,12 +60,13 @@ pub async fn get_record(
_headers: HeaderMap,
Query(input): Query<GetRecordInput>,
) -> Response {
let user_id = match common::resolve_repo_user_id(state.user_repo.as_ref(), &input.repo).await {
let user_id = match common::resolve_repo_user_id(state.repos.user.as_ref(), &input.repo).await {
Ok(id) => id,
Err(e) => return e.into_response(),
};
let record_row = state
.repo_repo
.repos
.repo
.get_record_cid(user_id, &input.collection, &input.rkey)
.await;
let record_cid_link = match record_row {
@@ -128,7 +129,7 @@ pub async fn list_records(
State(state): State<AppState>,
Query(input): Query<ListRecordsInput>,
) -> Response {
let user_id = match common::resolve_repo_user_id(state.user_repo.as_ref(), &input.repo).await {
let user_id = match common::resolve_repo_user_id(state.repos.user.as_ref(), &input.repo).await {
Ok(id) => id,
Err(e) => return e.into_response(),
};
@@ -139,7 +140,8 @@ pub async fn list_records(
.as_ref()
.and_then(|c| c.parse::<tranquil_pds::types::Rkey>().ok());
let rows = match state
.repo_repo
.repos
.repo
.list_records(
user_id,
&input.collection,
@@ -176,7 +178,7 @@ pub async fn list_records(
};
let records: Vec<Value> = parsed_rows
.iter()
.zip(blocks.into_iter())
.zip(blocks)
.filter_map(|((_, rkey, cid_str), block_opt)| {
block_opt.and_then(|block| {
serde_ipld_dagcbor::from_slice::<Ipld>(&block)
+67 -74
View File
@@ -46,7 +46,8 @@ pub async fn prepare_repo_write<A: RepoScopeAction>(
let _account_verified = require_verified_or_delegated(state, user).await?;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(principal_did.as_did())
.await
.log_db_err("fetching user for repo write")?
@@ -128,7 +129,8 @@ pub async fn create_record(
if !backlinks.is_empty() {
let conflicts = state
.backlink_repo
.repos
.backlink
.get_backlink_conflicts(user_id, &input.collection, &backlinks)
.await
.log_db_err("checking backlink conflicts")?;
@@ -145,30 +147,50 @@ pub async fn create_record(
let prev_cid = match mst.get(&conflict_key).await {
Ok(Some(cid)) => cid,
_ => continue,
};
mst = match mst.delete(&conflict_key).await {
Ok(m) => m,
Ok(None) => continue,
Err(e) => {
error!(
"Failed to delete conflict from MST {}: {:?}",
"Failed to read conflict record from MST {}: {:?}",
conflict_uri, e
);
continue;
return Err(ApiError::InternalError(Some(
"Failed to read conflicting record from MST".into(),
)));
}
};
mst = mst.delete(&conflict_key).await.map_err(|e| {
error!(
"Failed to delete conflict from MST {}: {:?}",
conflict_uri, e
);
ApiError::InternalError(Some(
"Failed to delete conflicting record from MST".into(),
))
})?;
ops.push(RecordOp::Delete {
collection: conflict_collection,
rkey: conflict_rkey,
prev: Some(prev_cid),
prev: tranquil_pds::cid_types::RecordCid::from(prev_cid),
});
conflict_uris_to_cleanup.push(conflict_uri);
}
}
}
let key = format!("{}/{}", input.collection, rkey);
if mst
.get(&key)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
.is_some()
{
return Err(ApiError::InvalidRequest(format!(
"Record already exists at {key}"
)));
}
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
@@ -177,8 +199,6 @@ pub async fn create_record(
.put(&record_bytes)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
let key = format!("{}/{}", input.collection, rkey);
mst = mst
.add(&key, record_cid)
.await
@@ -187,25 +207,14 @@ pub async fn create_record(
ops.push(RecordOp::Create {
collection: input.collection.clone(),
rkey: rkey.clone(),
cid: record_cid,
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
});
let modified_keys: Vec<String> = ops
.iter()
.map(|op| match op {
RecordOp::Create {
collection, rkey, ..
}
| RecordOp::Update {
collection, rkey, ..
}
| RecordOp::Delete {
collection, rkey, ..
} => format!("{}/{}", collection, rkey),
})
.collect();
let blob_cids = extract_blob_cids(&input.record);
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
let backlinks_to_add = extract_backlinks(&created_uri, &input.record);
let commit_result = finalize_repo_write(
&state,
ctx,
@@ -222,33 +231,13 @@ pub async fn create_record(
})
}),
ops,
modified_keys: &modified_keys,
blob_cids: &blob_cids,
backlinks_to_add,
backlinks_to_remove: conflict_uris_to_cleanup,
},
)
.await?;
{
let backlink_repo = state.backlink_repo.clone();
futures::future::join_all(conflict_uris_to_cleanup.iter().map(|uri| {
let backlink_repo = backlink_repo.clone();
async move {
if let Err(e) = backlink_repo.remove_backlinks_by_uri(uri).await {
error!("Failed to remove backlinks for {}: {}", uri, e);
}
}
}))
.await;
}
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
let backlinks = extract_backlinks(&created_uri, &input.record);
if !backlinks.is_empty()
&& let Err(e) = state.backlink_repo.add_backlinks(user_id, &backlinks).await
{
error!("Failed to add backlinks for {}: {}", created_uri, e);
}
Ok(Json(CreateRecordOutput {
uri: created_uri,
cid: record_cid.to_string(),
@@ -344,34 +333,37 @@ pub async fn put_record(
}));
}
let is_update = existing_cid.is_some();
let new_mst = if is_update {
mst.update(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?
} else {
mst.add(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?
};
let op = if is_update {
RecordOp::Update {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
cid: record_cid,
prev: existing_cid,
let record_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
let (new_mst, op, is_update, backlinks_to_remove) = match existing_cid {
Some(prev_cid) => {
let new_mst = mst
.update(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
let op = RecordOp::Update {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
prev: tranquil_pds::cid_types::RecordCid::from(prev_cid),
};
(new_mst, op, true, vec![record_uri.clone()])
}
} else {
RecordOp::Create {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
cid: record_cid,
None => {
let new_mst = mst
.add(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
let op = RecordOp::Create {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
};
(new_mst, op, false, vec![])
}
};
let modified_keys = [key];
let blob_cids = extract_blob_cids(&input.record);
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
let commit_result = finalize_repo_write(
&state,
@@ -389,14 +381,15 @@ pub async fn put_record(
})
}),
ops: vec![op],
modified_keys: &modified_keys,
blob_cids: &blob_cids,
backlinks_to_add,
backlinks_to_remove,
},
)
.await?;
Ok(Json(PutRecordOutput {
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
uri: record_uri,
cid: record_cid.to_string(),
commit: Some(CommitInfo {
cid: commit_result.commit_cid.to_string(),
@@ -41,24 +41,27 @@ pub async fn check_account_status(
) -> Result<Json<CheckAccountStatusOutput>, ApiError> {
let did = &auth.did;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(did)
.await
.log_db_err("fetching user ID for account status")?
.ok_or(ApiError::InternalError(None))?;
let is_active = state
.user_repo
.repos
.user
.is_account_active_by_did(did)
.await
.ok()
.flatten()
.unwrap_or(false);
let repo_info = state.repo_repo.get_repo(user_id).await.ok().flatten();
let repo_info = state.repos.repo.get_repo(user_id).await.ok().flatten();
let (repo_commit, repo_rev_from_db) = repo_info
.map(|r| (r.repo_root_cid.to_string(), r.repo_rev))
.unwrap_or_else(|| (String::new(), None));
let block_count: i64 = state
.repo_repo
.repos
.repo
.count_user_blocks(user_id)
.await
.unwrap_or(0);
@@ -80,19 +83,21 @@ pub async fn check_account_status(
} else {
String::new()
};
let record_count: i64 = state.repo_repo.count_records(user_id).await.unwrap_or(0);
let record_count: i64 = state.repos.repo.count_records(user_id).await.unwrap_or(0);
let imported_blobs: i64 = state
.blob_repo
.repos
.blob
.count_blobs_by_user(user_id)
.await
.unwrap_or(0);
let expected_blobs: i64 = state
.blob_repo
.repos
.blob
.count_distinct_record_blobs(user_id)
.await
.unwrap_or(0);
let valid_did =
is_valid_did_for_service(state.user_repo.as_ref(), state.cache.clone(), did).await;
is_valid_did_for_service(state.repos.user.as_ref(), state.cache.clone(), did).await;
Ok(Json(CheckAccountStatusOutput {
activated: is_active,
valid_did,
@@ -319,7 +324,7 @@ pub async fn activate_account(
);
let did_validation_start = std::time::Instant::now();
if let Err(e) = assert_valid_did_document_for_service(
state.user_repo.as_ref(),
state.repos.user.as_ref(),
state.cache.clone(),
&did,
true,
@@ -339,12 +344,18 @@ pub async fn activate_account(
did_validation_start.elapsed()
);
let handle = state.user_repo.get_handle_by_did(&did).await.ok().flatten();
let handle = state
.repos
.user
.get_handle_by_did(&did)
.await
.ok()
.flatten();
info!(
"[MIGRATION] activateAccount: Activating account did={} handle={:?}",
did, handle
);
let result = state.user_repo.activate_account(&did).await;
let result = state.repos.user.activate_account(&did).await;
match result {
Ok(_) => {
info!(
@@ -365,12 +376,20 @@ pub async fn activate_account(
.cache
.delete(&tranquil_pds::cache_keys::plc_data_key(&did))
.await;
if state.did_resolver.refresh_did(did.as_str()).await.is_none() {
if state.did_resolver.refresh_did(did.as_str()).await.is_err() {
warn!(
"[MIGRATION] activateAccount: Failed to refresh DID cache for {}",
did
);
}
if let Err(e) = state
.repos
.repo
.update_repo_status(&did, None, None, Some(false))
.await
{
warn!("failed to sync activation to repo backend: {e:?}");
}
info!(
"[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}",
did
@@ -406,7 +425,8 @@ pub async fn activate_account(
info!("[MIGRATION] activateAccount: Identity event sequenced successfully");
}
let repo_root = state
.repo_repo
.repos
.repo
.get_repo_root_by_did(&did)
.await
.ok()
@@ -480,9 +500,19 @@ pub async fn deactivate_account(
let did = auth.did.clone();
let handle = state.user_repo.get_handle_by_did(&did).await.ok().flatten();
let handle = state
.repos
.user
.get_handle_by_did(&did)
.await
.ok()
.flatten();
let result = state.user_repo.deactivate_account(&did, delete_after).await;
let result = state
.repos
.user
.deactivate_account(&did, delete_after)
.await;
match result {
Ok(true) => {
@@ -492,6 +522,14 @@ pub async fn deactivate_account(
.delete(&tranquil_pds::cache_keys::handle_key(h))
.await;
}
if let Err(e) = state
.repos
.repo
.update_repo_status(&did, None, None, Some(true))
.await
{
warn!("failed to sync deactivation to repo backend: {e:?}");
}
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
&state,
&did,
@@ -518,7 +556,8 @@ pub async fn request_account_delete(
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(session_mfa.did())
.await
.ok()
@@ -527,14 +566,15 @@ pub async fn request_account_delete(
let confirmation_token = Uuid::new_v4().to_string();
let expires_at = Utc::now() + Duration::minutes(15);
state
.infra_repo
.repos
.infra
.create_deletion_request(&confirmation_token, session_mfa.did(), expires_at)
.await
.log_db_err("creating deletion token")?;
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_account_deletion(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
&confirmation_token,
hostname,
@@ -572,7 +612,8 @@ pub async fn delete_account(
return Err(ApiError::InvalidToken(Some("token is required".into())));
}
let user = state
.user_repo
.repos
.user
.get_user_for_deletion(did)
.await
.map_err(|e| {
@@ -582,7 +623,7 @@ pub async fn delete_account(
.ok_or(ApiError::InvalidRequest("account not found".into()))?;
let (user_id, password_hash, handle) = (user.id, user.password_hash, user.handle);
if crate::common::verify_credential(
state.session_repo.as_ref(),
state.repos.session.as_ref(),
user_id,
password,
password_hash.as_deref(),
@@ -595,7 +636,8 @@ pub async fn delete_account(
)));
}
let deletion_request = state
.infra_repo
.repos
.infra
.get_deletion_request(token)
.await
.map_err(|e| {
@@ -611,11 +653,12 @@ pub async fn delete_account(
)));
}
if Utc::now() > deletion_request.expires_at {
let _ = state.infra_repo.delete_deletion_request(token).await;
let _ = state.repos.infra.delete_deletion_request(token).await;
return Err(ApiError::ExpiredToken(None));
}
state
.user_repo
.repos
.user
.delete_account_complete(user_id, did)
.await
.map_err(|e| {
@@ -630,7 +673,7 @@ pub async fn delete_account(
.await;
match account_seq {
Ok(seq) => {
if let Err(e) = state.repo_repo.delete_sequences_except(did, seq).await {
if let Err(e) = state.repos.repo.delete_sequences_except(did, seq).await {
warn!(
"Failed to cleanup sequences for deleted account {}: {}",
did, e
+31 -25
View File
@@ -1,7 +1,6 @@
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::error;
use tranquil_db_traits::AppPasswordCreate;
use tranquil_pds::api::EmptyResponse;
use tranquil_pds::api::error::{ApiError, DbResultExt};
@@ -32,14 +31,16 @@ pub async fn list_app_passwords(
auth: Auth<Permissive>,
) -> Result<Json<ListAppPasswordsOutput>, ApiError> {
let user = state
.user_repo
.repos
.user
.get_by_did(&auth.did)
.await
.log_db_err("getting user")?
.ok_or(ApiError::AccountNotFound)?;
let rows = state
.session_repo
.repos
.session
.list_app_passwords(user.id)
.await
.log_db_err("listing app passwords")?;
@@ -84,7 +85,8 @@ pub async fn create_app_password(
Json(input): Json<CreateAppPasswordInput>,
) -> Result<Json<CreateAppPasswordOutput>, ApiError> {
let user = state
.user_repo
.repos
.user
.get_by_did(&auth.did)
.await
.log_db_err("getting user")?
@@ -96,7 +98,8 @@ pub async fn create_app_password(
}
if state
.session_repo
.repos
.session
.get_app_password_by_name(user.id, name)
.await
.log_db_err("checking app password")?
@@ -107,7 +110,8 @@ pub async fn create_app_password(
let (final_scopes, controller_did) = if let Some(ref controller) = auth.controller_did {
let grant = state
.delegation_repo
.repos
.delegation
.get_delegation(&auth.did, controller)
.await
.ok()
@@ -128,23 +132,19 @@ pub async fn create_app_password(
};
(scope_result, Some(controller.clone()))
} else {
(input.scopes.clone(), None)
let scopes = match input.scopes {
Some(ref s) => s.clone(),
None => match input.privileged {
Some(false) => "transition:generic".to_string(),
_ => "transition:generic transition:chat.bsky".to_string(),
},
};
(Some(scopes), None)
};
let password = generate_app_password();
let password_clone = password.clone();
let password_hash =
tokio::task::spawn_blocking(move || bcrypt::hash(&password_clone, bcrypt::DEFAULT_COST))
.await
.map_err(|e| {
error!("Failed to spawn blocking task: {:?}", e);
ApiError::InternalError(None)
})?
.map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})?;
let password_hash = crate::common::hash_password_async(&password).await?;
let privilege = tranquil_db_traits::AppPasswordPrivilege::from_privileged_flag(
input.privileged.unwrap_or(false),
@@ -161,14 +161,16 @@ pub async fn create_app_password(
};
state
.session_repo
.repos
.session
.create_app_password(&create_data)
.await
.log_db_err("creating app password")?;
if let Some(ref controller) = controller_did {
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&auth.did,
controller,
@@ -204,7 +206,8 @@ pub async fn revoke_app_password(
Json(input): Json<RevokeAppPasswordInput>,
) -> Result<Json<EmptyResponse>, ApiError> {
let user = state
.user_repo
.repos
.user
.get_by_did(&auth.did)
.await
.log_db_err("getting user")?
@@ -216,13 +219,15 @@ pub async fn revoke_app_password(
}
let sessions_to_invalidate = state
.session_repo
.repos
.session
.get_session_jtis_by_app_password(&auth.did, name)
.await
.unwrap_or_default();
state
.session_repo
.repos
.session
.delete_sessions_by_app_password(&auth.did, name)
.await
.log_db_err("revoking sessions for app password")?;
@@ -237,7 +242,8 @@ pub async fn revoke_app_password(
.await;
state
.session_repo
.repos
.session
.delete_app_password(user.id, name)
.await
.log_db_err("revoking app password")?;
+41 -44
View File
@@ -22,10 +22,6 @@ use tranquil_pds::state::AppState;
const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60);
fn email_update_cache_key(did: &str) -> String {
tranquil_pds::cache_keys::email_update_key(did)
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
@@ -39,6 +35,16 @@ struct PendingEmailUpdate {
authorized: bool,
}
async fn get_pending_email_update(
cache: &dyn tranquil_pds::cache::Cache,
did: &str,
) -> Option<PendingEmailUpdate> {
cache
.get(&tranquil_pds::cache_keys::email_update_key(did))
.await
.and_then(|json| serde_json::from_str(&json).ok())
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestEmailUpdateInput {
@@ -55,7 +61,8 @@ pub async fn request_email_update(
auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?;
let user = state
.user_repo
.repos
.user
.get_email_info_by_did(&auth.did)
.await
.log_db_err("getting email info")?
@@ -92,7 +99,7 @@ pub async fn request_email_update(
authorized: false,
};
if let Ok(json) = serde_json::to_string(&pending) {
let cache_key = email_update_cache_key(&auth.did);
let cache_key = tranquil_pds::cache_keys::email_update_key(&auth.did);
if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await {
warn!("Failed to cache pending email update: {:?}", e);
}
@@ -102,8 +109,8 @@ pub async fn request_email_update(
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_short_token_email(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user.id,
&token,
hostname,
@@ -135,7 +142,8 @@ pub async fn confirm_email(
let did = &auth.did;
let user = state
.user_repo
.repos
.user
.get_email_info_by_did(did)
.await
.log_db_err("getting email info")?
@@ -179,7 +187,8 @@ pub async fn confirm_email(
}
state
.user_repo
.repos
.user
.set_email_verified(user.id, true)
.await
.log_db_err("confirming email")?;
@@ -206,7 +215,8 @@ pub async fn update_email(
let did = &auth.did;
let user = state
.user_repo
.repos
.user
.get_email_info_by_did(did)
.await
.log_db_err("getting email info")?
@@ -253,7 +263,8 @@ pub async fn update_email(
}
state
.infra_repo
.repos
.infra
.upsert_account_preference(user_id, "email_auth_factor", json!(email_auth_factor))
.await
.map_err(|e| {
@@ -267,7 +278,7 @@ pub async fn update_email(
if email_verified {
let mut authorized_via_link = false;
let cache_key = email_update_cache_key(did);
let cache_key = tranquil_pds::cache_keys::email_update_key(did);
if let Some(pending_json) = state.cache.get(&cache_key).await
&& let Ok(pending) = serde_json::from_str::<PendingEmailUpdate>(&pending_json)
&& pending.authorized
@@ -336,7 +347,8 @@ pub async fn update_email(
}
state
.user_repo
.repos
.user
.update_email(user_id, &new_email)
.await
.log_db_err("updating email")?;
@@ -350,8 +362,8 @@ pub async fn update_email(
tranquil_pds::auth::verification_token::format_token_for_display(&verification_token);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
tranquil_db_traits::CommsChannel::Email,
&new_email,
@@ -364,7 +376,8 @@ pub async fn update_email(
}
if let Err(e) = state
.infra_repo
.repos
.infra
.upsert_account_preference(
user_id,
"email_auth_factor",
@@ -390,7 +403,8 @@ pub async fn check_email_verified(
Json(input): Json<CheckEmailVerifiedInput>,
) -> Result<Json<VerifiedResponse>, ApiError> {
let verified = state
.user_repo
.repos
.user
.check_email_verified_by_identifier(&input.identifier)
.await
.map_err(|e| {
@@ -414,7 +428,8 @@ pub async fn check_channel_verified(
Json(input): Json<CheckChannelVerifiedInput>,
) -> Result<Json<VerifiedResponse>, ApiError> {
let verified = state
.user_repo
.repos
.user
.check_channel_verified_by_did(&input.did, input.channel)
.await
.map_err(|e| {
@@ -470,9 +485,9 @@ pub async fn authorize_email_update(
let did = token_data.did;
info!("authorize_email_update: token valid for did={}", did);
let cache_key = email_update_cache_key(&did);
let pending_json = match state.cache.get(&cache_key).await {
Some(json) => json,
let cache_key = tranquil_pds::cache_keys::email_update_key(&did);
let mut pending = match get_pending_email_update(state.cache.as_ref(), &did).await {
Some(p) => p,
None => {
warn!(
"authorize_email_update: no pending email update in cache for did={}",
@@ -483,13 +498,6 @@ pub async fn authorize_email_update(
}
};
let mut pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
Ok(p) => p,
Err(_) => {
return ApiError::InternalError(None).into_response();
}
};
let token_hash = hash_token(&query.token);
if pending
.token_hash
@@ -528,9 +536,8 @@ pub async fn check_email_update_status(
) -> Result<Json<EmailUpdateStatusOutput>, ApiError> {
auth.check_account_scope(AccountAttr::Email, AccountAction::Read)?;
let cache_key = email_update_cache_key(&auth.did);
let pending_json = match state.cache.get(&cache_key).await {
Some(json) => json,
let pending = match get_pending_email_update(state.cache.as_ref(), &auth.did).await {
Some(p) => p,
None => {
return Ok(Json(EmailUpdateStatusOutput {
pending: false,
@@ -540,17 +547,6 @@ pub async fn check_email_update_status(
}
};
let pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
Ok(p) => p,
Err(_) => {
return Ok(Json(EmailUpdateStatusOutput {
pending: false,
authorized: false,
new_email: None,
}));
}
};
Ok(Json(EmailUpdateStatusOutput {
pending: true,
authorized: pending.authorized,
@@ -574,7 +570,8 @@ pub async fn check_email_in_use(
}
let count = state
.user_repo
.repos
.user
.count_accounts_by_email(&email)
.await
.map_err(|e| {
+9 -24
View File
@@ -1,5 +1,4 @@
use axum::{Json, extract::State};
use rand::Rng;
use serde::{Deserialize, Serialize};
use tracing::error;
use tranquil_pds::api::ApiError;
@@ -7,24 +6,7 @@ use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::auth::{Admin, Auth, NotTakendown};
use tranquil_pds::state::AppState;
use tranquil_pds::types::Did;
const BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
pub(crate) fn gen_random_token() -> String {
let mut rng = rand::thread_rng();
let gen_segment = |rng: &mut rand::rngs::ThreadRng, len: usize| -> String {
(0..len)
.map(|_| BASE32_ALPHABET[rng.gen_range(0..32)] as char)
.collect()
};
format!("{}-{}", gen_segment(&mut rng, 5), gen_segment(&mut rng, 5))
}
pub fn gen_invite_code() -> String {
let hostname = &tranquil_config::get().server.hostname;
let hostname_prefix = hostname.replace('.', "-");
format!("{}-{}", hostname_prefix, gen_random_token())
}
use tranquil_pds::util::gen_invite_code;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -58,7 +40,8 @@ pub async fn create_invite_code(
let code = gen_invite_code();
match state
.infra_repo
.repos
.infra
.create_invite_code(&code, input.use_count, Some(&for_account))
.await
{
@@ -115,7 +98,8 @@ pub async fn create_invite_codes(
};
let admin_user_id = state
.user_repo
.repos
.user
.get_any_admin_user_id()
.await
.log_db_err("looking up admin user")?
@@ -125,7 +109,7 @@ pub async fn create_invite_codes(
})?;
let result = futures::future::try_join_all(for_accounts.into_iter().map(|account| {
let infra_repo = state.infra_repo.clone();
let infra_repo = state.repos.infra.clone();
let use_count = input.use_count;
async move {
let codes: Vec<String> = (0..code_count).map(|_| gen_invite_code()).collect();
@@ -192,7 +176,8 @@ pub async fn get_account_invite_codes(
let include_used = params.include_used.unwrap_or(true);
let codes_info = state
.infra_repo
.repos
.infra
.get_invite_codes_for_account(&auth.did)
.await
.log_db_err("fetching invite codes")?;
@@ -203,7 +188,7 @@ pub async fn get_account_invite_codes(
.collect();
let codes = futures::future::join_all(filtered_codes.into_iter().map(|info| {
let infra_repo = state.infra_repo.clone();
let infra_repo = state.repos.infra.clone();
async move {
let uses = infra_repo
.get_invite_code_uses(&info.code)
+2 -2
View File
@@ -9,7 +9,7 @@ use tracing::error;
use tranquil_pds::state::AppState;
pub async fn get_logo(State(state): State<AppState>) -> Response {
let logo_cid = match state.infra_repo.get_server_config("logo_cid").await {
let logo_cid = match state.repos.infra.get_server_config("logo_cid").await {
Ok(cid) => cid,
Err(e) => {
error!("DB error fetching logo_cid: {:?}", e);
@@ -26,7 +26,7 @@ pub async fn get_logo(State(state): State<AppState>) -> Response {
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let metadata = match state.blob_repo.get_blob_metadata(&cid).await {
let metadata = match state.repos.blob.get_blob_metadata(&cid).await {
Ok(Some(m)) => m,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => {
+7 -5
View File
@@ -5,7 +5,7 @@ use tranquil_pds::BUILD_VERSION;
use tranquil_pds::state::AppState;
use tranquil_pds::util::{discord_app_id, discord_bot_username, telegram_bot_username};
fn get_available_comms_channels() -> Vec<CommsChannel> {
async fn get_available_comms_channels(state: &AppState) -> Vec<CommsChannel> {
let cfg = tranquil_config::get();
let mut channels = vec![CommsChannel::Email];
if cfg.discord.bot_token.is_some() {
@@ -14,7 +14,9 @@ fn get_available_comms_channels() -> Vec<CommsChannel> {
if cfg.telegram.bot_token.is_some() {
channels.push(CommsChannel::Telegram);
}
if cfg.signal.sender_number.is_some() {
if let Some(slot) = &state.signal_sender
&& slot.is_linked().await
{
channels.push(CommsChannel::Signal);
}
channels
@@ -66,7 +68,7 @@ pub struct DescribeServerOutput {
pub telegram_bot_username: Option<String>,
}
pub async fn describe_server() -> Json<DescribeServerOutput> {
pub async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerOutput> {
let cfg = tranquil_config::get();
let pds_hostname = &cfg.server.hostname;
@@ -82,7 +84,7 @@ pub async fn describe_server() -> Json<DescribeServerOutput> {
email: cfg.server.contact_email.clone(),
},
version: BUILD_VERSION,
available_comms_channels: get_available_comms_channels(),
available_comms_channels: get_available_comms_channels(&state).await,
self_hosted_did_web_enabled: is_self_hosted_did_web_enabled(),
discord_bot_username: discord_bot_username().map(String::from),
discord_app_id: discord_app_id().map(String::from),
@@ -98,7 +100,7 @@ pub struct HealthOutput {
}
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
match state.infra_repo.health_check().await {
match state.repos.infra.health_check().await {
Ok(true) => (
StatusCode::OK,
Json(HealthOutput {
+11 -6
View File
@@ -42,7 +42,8 @@ pub async fn update_did_document(
}
let user = state
.user_repo
.repos
.user
.get_user_for_did_doc(&auth.did)
.await
.log_db_err("getting user")?
@@ -97,7 +98,8 @@ pub async fn update_did_document(
let also_known_as: Option<Vec<String>> = input.also_known_as.clone();
state
.user_repo
.repos
.user
.upsert_did_web_overrides(user.id, verification_methods_json, also_known_as)
.await
.log_db_err("upserting did_web_overrides")?;
@@ -105,7 +107,8 @@ pub async fn update_did_document(
if let Some(ref endpoint) = input.service_endpoint {
let endpoint_clean = endpoint.trim().trim_end_matches('/');
state
.user_repo
.repos
.user
.update_migrated_to_pds(&auth.did, endpoint_clean)
.await
.log_db_err("updating service endpoint")?;
@@ -139,7 +142,7 @@ pub async fn get_did_document(
async fn build_did_document(state: &AppState, did: &tranquil_pds::types::Did) -> serde_json::Value {
let hostname = &tranquil_config::get().server.hostname;
let user = match state.user_repo.get_user_for_did_doc_build(did).await {
let user = match state.repos.user.get_user_for_did_doc_build(did).await {
Ok(Some(row)) => row,
_ => {
return json!({
@@ -149,7 +152,8 @@ async fn build_did_document(state: &AppState, did: &tranquil_pds::types::Did) ->
};
let overrides = state
.user_repo
.repos
.user
.get_did_web_overrides(user.id)
.await
.ok()
@@ -193,7 +197,8 @@ async fn build_did_document(state: &AppState, did: &tranquil_pds::types::Did) ->
}
let key_info = state
.user_repo
.repos
.user
.get_user_key_by_id(user.id)
.await
.ok()
+1
View File
@@ -51,6 +51,7 @@ pub use session::{
auto_resend_verification, confirm_signup, create_session, delete_session,
get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification,
revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale,
verification_blocks_login,
};
pub use signing_key::reserve_signing_key;
pub use totp::{
@@ -120,7 +120,7 @@ pub async fn create_passkey_account(
}
let is_bootstrap = state.bootstrap_invite_code.is_some()
&& state.user_repo.count_users().await.unwrap_or(1) == 0;
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
let _validated_invite_code = if is_bootstrap {
match input.invite_code.as_deref() {
@@ -128,7 +128,7 @@ pub async fn create_passkey_account(
_ => return Err(ApiError::InvalidInviteCode),
}
} else if let Some(ref code) = input.invite_code {
match state.infra_repo.validate_invite_code(code).await {
match state.repos.infra.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return Err(ApiError::InvalidInviteCode),
}
@@ -351,7 +351,7 @@ pub async fn create_passkey_account(
birthdate_pref,
};
let create_result = match state.user_repo.create_passkey_account(&create_input).await {
let create_result = match state.repos.user.create_passkey_account(&create_input).await {
Ok(r) => r,
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
return Err(ApiError::HandleNotAvailable(None));
@@ -393,19 +393,19 @@ pub async fn create_passkey_account(
Ok(token_meta) => {
let refresh_jti = uuid::Uuid::new_v4().to_string();
let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24);
let session_data = tranquil_db::SessionTokenCreate {
let session_data = tranquil_db_traits::SessionTokenCreate {
did: did_typed.clone(),
access_jti: token_meta.jti.clone(),
refresh_jti,
access_expires_at: token_meta.expires_at,
refresh_expires_at: refresh_expires,
login_type: tranquil_db::LoginType::Modern,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: Some("transition:generic".to_string()),
scope: Some("transition:generic transition:chat.bsky".to_string()),
controller_did: None,
app_password_name: None,
};
if let Err(e) = state.session_repo.create_session(&session_data).await {
if let Err(e) = state.repos.session.create_session(&session_data).await {
warn!(did = %did, "Failed to insert migration session: {:?}", e);
}
info!(did = %did, "Generated migration access token for BYOD passkey account");
@@ -451,7 +451,12 @@ pub async fn complete_passkey_setup(
State(state): State<AppState>,
Json(input): Json<CompletePasskeySetupInput>,
) -> Result<Json<CompletePasskeySetupOutput>, ApiError> {
let user = match state.user_repo.get_user_for_passkey_setup(&input.did).await {
let user = match state
.repos
.user
.get_user_for_passkey_setup(&input.did)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return Err(ApiError::AccountNotFound);
@@ -484,7 +489,8 @@ pub async fn complete_passkey_setup(
let webauthn = &state.webauthn_config;
let reg_state = match state
.user_repo
.repos
.user
.load_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
.await
{
@@ -530,7 +536,8 @@ pub async fn complete_passkey_setup(
}
};
if let Err(e) = state
.user_repo
.repos
.user
.save_passkey(
&input.did,
&credential_id,
@@ -553,13 +560,14 @@ pub async fn complete_passkey_setup(
app_password_name: app_password_name.clone(),
app_password_hash: password_hash,
};
if let Err(e) = state.user_repo.complete_passkey_setup(&setup_input).await {
if let Err(e) = state.repos.user.complete_passkey_setup(&setup_input).await {
error!("Error completing passkey setup: {:?}", e);
return Err(ApiError::InternalError(None));
}
let _ = state
.user_repo
.repos
.user
.delete_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
.await;
@@ -577,7 +585,12 @@ pub async fn start_passkey_registration_for_setup(
State(state): State<AppState>,
Json(input): Json<StartPasskeyRegistrationInput>,
) -> Result<Json<OptionsResponse<serde_json::Value>>, ApiError> {
let user = match state.user_repo.get_user_for_passkey_setup(&input.did).await {
let user = match state
.repos
.user
.get_user_for_passkey_setup(&input.did)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return Err(ApiError::AccountNotFound);
@@ -610,7 +623,8 @@ pub async fn start_passkey_registration_for_setup(
let webauthn = &state.webauthn_config;
let existing_passkeys = state
.user_repo
.repos
.user
.get_passkeys_for_user(&input.did)
.await
.unwrap_or_default();
@@ -643,7 +657,8 @@ pub async fn start_passkey_registration_for_setup(
}
};
if let Err(e) = state
.user_repo
.repos
.user
.save_webauthn_challenge(&input.did, WebauthnChallengeType::Registration, &state_json)
.await
{
@@ -682,7 +697,8 @@ pub async fn request_passkey_recovery(
NormalizedLoginIdentifier::normalize(&input.email, hostname_for_handles);
let user = match state
.user_repo
.repos
.user
.get_user_for_passkey_recovery(identifier, normalized_handle.as_str())
.await
{
@@ -697,7 +713,8 @@ pub async fn request_passkey_recovery(
let expires_at = Utc::now() + Duration::hours(1);
if let Err(e) = state
.user_repo
.repos
.user
.set_recovery_token(&user.did, &recovery_token_hash, expires_at)
.await
{
@@ -714,8 +731,8 @@ pub async fn request_passkey_recovery(
);
let _ = tranquil_pds::comms::comms_repo::enqueue_passkey_recovery(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user.id,
&recovery_url,
hostname,
@@ -742,7 +759,7 @@ pub async fn recover_passkey_account(
return Err(ApiError::InvalidRequest(e.to_string()));
}
let user = match state.user_repo.get_user_for_recovery(&input.did).await {
let user = match state.repos.user.get_user_for_recovery(&input.did).await {
Ok(Some(u)) => u,
_ => {
return Err(ApiError::InvalidRecoveryLink);
@@ -771,7 +788,8 @@ pub async fn recover_passkey_account(
password_hash,
};
let result = match state
.user_repo
.repos
.user
.recover_passkey_account(&recover_input)
.await
{
@@ -785,11 +803,12 @@ pub async fn recover_passkey_account(
if result.passkeys_deleted > 0 {
info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery");
}
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user.id).await {
if let Ok(Some(prefs)) = state.repos.user.get_comms_prefs(user.id).await {
let actual_channel =
tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
if let Err(e) = state
.user_repo
.repos
.user
.set_channel_verified(&input.did, actual_channel)
.await
{
+18 -10
View File
@@ -28,14 +28,16 @@ pub async fn start_passkey_registration(
let webauthn = &state.webauthn_config;
let handle = state
.user_repo
.repos
.user
.get_handle_by_did(&auth.did)
.await
.log_db_err("fetching user")?
.ok_or(ApiError::AccountNotFound)?;
let existing_passkeys = state
.user_repo
.repos
.user
.get_passkeys_for_user(&auth.did)
.await
.log_db_err("fetching existing passkeys")?;
@@ -60,7 +62,8 @@ pub async fn start_passkey_registration(
})?;
state
.user_repo
.repos
.user
.save_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration, &state_json)
.await
.log_db_err("saving registration state")?;
@@ -94,7 +97,8 @@ pub async fn finish_passkey_registration(
let webauthn = &state.webauthn_config;
let reg_state_json = state
.user_repo
.repos
.user
.load_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration)
.await
.log_db_err("loading registration state")?
@@ -125,7 +129,8 @@ pub async fn finish_passkey_registration(
})?;
let passkey_id = state
.user_repo
.repos
.user
.save_passkey(
&auth.did,
passkey.cred_id(),
@@ -136,7 +141,8 @@ pub async fn finish_passkey_registration(
.log_db_err("saving passkey")?;
if let Err(e) = state
.user_repo
.repos
.user
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration)
.await
{
@@ -177,7 +183,8 @@ pub async fn list_passkeys(
auth: Auth<Active>,
) -> Result<Json<ListPasskeysOutput>, ApiError> {
let passkeys = state
.user_repo
.repos
.user
.get_passkeys_for_user(&auth.did)
.await
.log_db_err("fetching passkeys")?;
@@ -215,7 +222,7 @@ pub async fn delete_passkey(
let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?;
match state.user_repo.delete_passkey(id, reauth_mfa.did()).await {
match state.repos.user.delete_passkey(id, reauth_mfa.did()).await {
Ok(true) => {
info!(did = %session_mfa.did(), passkey_id = %id, "Passkey deleted");
Ok(Json(EmptyResponse {}))
@@ -243,7 +250,8 @@ pub async fn update_passkey(
let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?;
match state
.user_repo
.repos
.user
.update_passkey_name(id, &auth.did, &input.friendly_name)
.await
{
@@ -260,5 +268,5 @@ pub async fn update_passkey(
}
pub async fn has_passkeys_for_user(state: &AppState, did: &tranquil_pds::types::Did) -> bool {
state.user_repo.has_passkeys(did).await.unwrap_or(false)
state.repos.user.has_passkeys(did).await.unwrap_or(false)
}
+33 -54
View File
@@ -1,5 +1,4 @@
use axum::{Json, extract::State};
use bcrypt::{DEFAULT_COST, hash};
use chrono::{Duration, Utc};
use serde::Deserialize;
use tracing::{error, info, warn};
@@ -42,7 +41,7 @@ pub async fn request_password_reset(
let normalized_handle = NormalizedLoginIdentifier::normalize(identifier, hostname_for_handles);
let multiple_accounts_warning = if is_email_lookup {
match state.user_repo.count_accounts_by_email(normalized).await {
match state.repos.user.count_accounts_by_email(normalized).await {
Ok(count) if count > 1 => Some(count),
_ => None,
}
@@ -51,7 +50,8 @@ pub async fn request_password_reset(
};
let user_id = match state
.user_repo
.repos
.user
.get_id_by_email_or_handle(normalized, normalized_handle.as_str())
.await
{
@@ -73,7 +73,8 @@ pub async fn request_password_reset(
let code = generate_reset_code();
let expires_at = Utc::now() + Duration::minutes(10);
if let Err(e) = state
.user_repo
.repos
.user
.set_password_reset_code(user_id, &code, expires_at)
.await
{
@@ -82,8 +83,8 @@ pub async fn request_password_reset(
}
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_password_reset(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
&code,
hostname,
@@ -132,7 +133,7 @@ pub async fn reset_password(
if let Err(e) = validate_password(password) {
return Err(ApiError::InvalidRequest(e.to_string()));
}
let user = match state.user_repo.get_user_by_reset_code(token).await {
let user = match state.repos.user.get_user_by_reset_code(token).await {
Ok(Some(u)) => u,
Ok(None) => {
return Err(ApiError::InvalidToken(None));
@@ -147,26 +148,15 @@ pub async fn reset_password(
return Err(ApiError::InvalidToken(None));
};
if Utc::now() > exp {
if let Err(e) = state.user_repo.clear_password_reset_code(user_id).await {
if let Err(e) = state.repos.user.clear_password_reset_code(user_id).await {
error!("Failed to clear expired reset code: {:?}", e);
}
return Err(ApiError::ExpiredToken(None));
}
let password_clone = password.to_string();
let password_hash =
match tokio::task::spawn_blocking(move || hash(password_clone, DEFAULT_COST)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => {
error!("Failed to hash password: {:?}", e);
return Err(ApiError::InternalError(None));
}
Err(e) => {
error!("Failed to spawn blocking task: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
let password_hash = crate::common::hash_password_async(password).await?;
let result = match state
.user_repo
.repos
.user
.reset_password_with_sessions(user_id, &password_hash)
.await
{
@@ -189,11 +179,12 @@ pub async fn reset_password(
}
}))
.await;
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user_id).await {
if let Ok(Some(prefs)) = state.repos.user.get_comms_prefs(user_id).await {
let actual_channel =
tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
if let Err(e) = state
.user_repo
.repos
.user
.set_channel_verified(&user.did, actual_channel)
.await
{
@@ -238,26 +229,18 @@ pub async fn change_password(
let password_mfa = verify_password_mfa(&state, &auth, &input.current_password).await?;
let user = state
.user_repo
.repos
.user
.get_id_and_password_hash_by_did(password_mfa.did())
.await
.log_db_err("in change_password")?
.ok_or(ApiError::AccountNotFound)?;
let new_password_clone = input.new_password.to_string();
let new_hash = tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST))
.await
.map_err(|e| {
error!("Failed to spawn blocking task: {:?}", e);
ApiError::InternalError(None)
})?
.map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})?;
let new_hash = crate::common::hash_password_async(&input.new_password).await?;
state
.user_repo
.repos
.user
.update_password_hash(user.id, &new_hash)
.await
.log_db_err("updating password")?;
@@ -271,7 +254,8 @@ pub async fn get_password_status(
auth: Auth<Active>,
) -> Result<Json<HasPasswordResponse>, ApiError> {
let has = state
.user_repo
.repos
.user
.has_password_by_did(&auth.did)
.await
.log_db_err("checking password status")?
@@ -288,7 +272,8 @@ pub async fn remove_password(
let reauth_mfa = require_reauth_window(&state, &auth).await?;
let has_passkeys = state
.user_repo
.repos
.user
.has_passkeys(reauth_mfa.did())
.await
.unwrap_or(false);
@@ -299,7 +284,8 @@ pub async fn remove_password(
}
let user = state
.user_repo
.repos
.user
.get_password_info_by_did(reauth_mfa.did())
.await
.log_db_err("getting password info")?
@@ -312,7 +298,8 @@ pub async fn remove_password(
}
state
.user_repo
.repos
.user
.remove_user_password(user.id)
.await
.log_db_err("removing password")?;
@@ -345,7 +332,8 @@ pub async fn set_password(
let did = reauth_mfa.as_ref().map(|m| m.did()).unwrap_or(&auth.did);
let user = state
.user_repo
.repos
.user
.get_password_info_by_did(did)
.await
.log_db_err("getting password info")?
@@ -357,20 +345,11 @@ pub async fn set_password(
));
}
let new_password_clone = new_password.to_string();
let new_hash = tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST))
.await
.map_err(|e| {
error!("Failed to spawn blocking task: {:?}", e);
ApiError::InternalError(None)
})?
.map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})?;
let new_hash = crate::common::hash_password_async(new_password).await?;
state
.user_repo
.repos
.user
.set_new_user_password(user.id, &new_hash)
.await
.log_db_err("setting password")?;
+22 -13
View File
@@ -33,13 +33,14 @@ pub async fn get_reauth_status(
auth: Auth<Active>,
) -> Result<Json<ReauthStatusOutput>, ApiError> {
let last_reauth_at = state
.session_repo
.repos
.session
.get_last_reauth_at(&auth.did)
.await
.log_db_err("getting last reauth")?;
let reauth_required = is_reauth_required(last_reauth_at);
let available_methods = get_available_reauth_methods(&*state.user_repo, &auth.did).await;
let available_methods = get_available_reauth_methods(&*state.repos.user, &auth.did).await;
Ok(Json(ReauthStatusOutput {
last_reauth_at,
@@ -66,7 +67,8 @@ pub async fn reauth_password(
Json(input): Json<PasswordReauthInput>,
) -> Result<Json<ReauthOutput>, ApiError> {
let password_hash = state
.user_repo
.repos
.user
.get_password_hash_by_did(&auth.did)
.await
.log_db_err("fetching password hash")?
@@ -76,7 +78,8 @@ pub async fn reauth_password(
if !password_valid {
let app_password_hashes = state
.session_repo
.repos
.session
.get_app_password_hashes_by_did(&auth.did)
.await
.unwrap_or_default();
@@ -91,7 +94,7 @@ pub async fn reauth_password(
}
}
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
let reauthed_at = update_last_reauth_cached(&*state.repos.session, &state.cache, &auth.did)
.await
.log_db_err("updating reauth")?;
@@ -127,7 +130,7 @@ pub async fn reauth_totp(
)));
}
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
let reauthed_at = update_last_reauth_cached(&*state.repos.session, &state.cache, &auth.did)
.await
.log_db_err("updating reauth")?;
@@ -146,7 +149,8 @@ pub async fn reauth_passkey_start(
auth: Auth<Active>,
) -> Result<Json<PasskeyReauthStartOutput>, ApiError> {
let stored_passkeys = state
.user_repo
.repos
.user
.get_passkeys_for_user(&auth.did)
.await
.log_db_err("getting passkeys")?;
@@ -179,7 +183,8 @@ pub async fn reauth_passkey_start(
})?;
state
.user_repo
.repos
.user
.save_webauthn_challenge(
&auth.did,
WebauthnChallengeType::Authentication,
@@ -204,7 +209,8 @@ pub async fn reauth_passkey_finish(
Json(input): Json<PasskeyReauthFinishInput>,
) -> Result<Json<ReauthOutput>, ApiError> {
let auth_state_json = state
.user_repo
.repos
.user
.load_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
.await
.log_db_err("loading authentication state")?
@@ -232,7 +238,8 @@ pub async fn reauth_passkey_finish(
let cred_id_bytes = auth_result.cred_id().as_ref();
match state
.user_repo
.repos
.user
.update_passkey_counter(
cred_id_bytes,
i32::try_from(auth_result.counter()).unwrap_or(i32::MAX),
@@ -242,7 +249,8 @@ pub async fn reauth_passkey_finish(
Ok(false) => {
warn!(did = %&auth.did, "Passkey counter anomaly detected - possible cloned key");
let _ = state
.user_repo
.repos
.user
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
.await;
return Err(ApiError::PasskeyCounterAnomaly);
@@ -254,11 +262,12 @@ pub async fn reauth_passkey_finish(
}
let _ = state
.user_repo
.repos
.user
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
.await;
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
let reauthed_at = update_last_reauth_cached(&*state.repos.session, &state.cache, &auth.did)
.await
.log_db_err("updating reauth")?;

Some files were not shown because too many files have changed in this diff Show More