adds ATproto ClientAuthStore implementation for Oauth login flow
test(atproto): add test harness, session round-trip and upsert tests Fix migration: add IF NOT EXISTS to indexes, remove LIMIT from trigger bodies (unsupported by SQLite), add missing semicolons after END test(atproto): add not-found, delete, and multi-session tests test(atproto): add auth request round-trip, create-only, and not-found tests test(atproto): add delete auth request and migration idempotency tests test(atproto): add GC trigger tests for expired sessions and auth requests Fix errcheck lint: explicitly ignore db.Close() return values
6 files changed,  +574, -8
M go.work.sum
+144, -7
  1@@ -7,51 +7,160 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB
  2 filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
  3 github.com/ClickHouse/ch-go v0.65.1/go.mod h1:bsodgURwmrkvkBe5jw1qnGDgyITsYErfONKAHn05nv4=
  4 github.com/ClickHouse/clickhouse-go/v2 v2.34.0/go.mod h1:yioSINoRLVZkLyDzdMXPLRIqhDvel8iLBlwh6Iefso8=
  5+github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
  6 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
  7+github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
  8+github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4=
  9+github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA=
 10 github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
 11+github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
 12+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 13+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 14+github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
 15+github.com/brianvoe/gofakeit/v6 v6.25.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
 16 github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
 17 github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=
 18+github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
 19+github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
 20+github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
 21+github.com/cockroachdb/pebble v1.1.2/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU=
 22+github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
 23+github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
 24 github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
 25+github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 26 github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
 27+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
 28+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 29 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
 30+github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
 31+github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
 32 github.com/elastic/go-sysinfo v1.15.3/go.mod h1:K/cNrqYTDrSoMh2oDkYEMS2+a72GRxMvNP+GC+vRIlo=
 33 github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8=
 34 github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
 35 github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
 36 github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
 37 github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
 38+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 39+github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
 40 github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
 41 github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
 42 github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
 43+github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
 44+github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
 45 github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
 46 github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
 47+github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4=
 48+github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
 49+github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
 50 github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
 51+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
 52+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 53 github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
 54 github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
 55 github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
 56 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 57+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 58+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 59 github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
 60+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
 61+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
 62+github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
 63+github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3KpyTy76kYUZA4W3pTv/wdKQ9Y=
 64+github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
 65 github.com/hamba/avro/v2 v2.30.0/go.mod h1:X6gDhYv6DQVAT56VqOKuW+PLnQrEQqGB9l1nhlMdAdQ=
 66+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
 67+github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
 68+github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
 69+github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
 70+github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno=
 71+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
 72 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 73+github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
 74+github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
 75+github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk=
 76+github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
 77+github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8=
 78+github.com/ipfs/go-ds-flatfs v0.5.1/go.mod h1:RWTV7oZD/yZYBKdbVIFXTX2fdY2Tbvl94NsWqmoyAX4=
 79+github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE=
 80+github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
 81+github.com/ipfs/go-ipfs-exchange-interface v0.2.1/go.mod h1:MUsYn6rKbG6CTtsDp+lKJPmVt3ZrCViNyH3rfPGsZ2E=
 82+github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs=
 83+github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk=
 84+github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg=
 85+github.com/ipfs/go-ipld-legacy v0.2.1/go.mod h1:782MOUghNzMO2DER0FlBR94mllfdCJCkTtDtPM51otM=
 86+github.com/ipfs/go-libipfs v0.7.0/go.mod h1:KsIf/03CqhICzyRGyGo68tooiBE2iFbI/rXW7FhAYr0=
 87+github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
 88+github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
 89+github.com/ipfs/go-merkledag v0.11.0/go.mod h1:Q4f/1ezvBiJV0YCIXvt51W/9/kqJGH4I1LsA7+djsM4=
 90+github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
 91+github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw=
 92+github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4/go.mod h1:6nkFF8OmR5wLKBzRKi7/YFJpyYR7+oEn1DX+mMWnlLA=
 93+github.com/ipld/go-car/v2 v2.13.1/go.mod h1:QkdjjFNGit2GIkpQ953KBwowuoukoM75nP/JI1iDJdo=
 94+github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s=
 95+github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ=
 96 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
 97 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
 98 github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
 99 github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
100+github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
101+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
102+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
103 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
104 github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
105 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
106+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
107+github.com/labstack/echo-contrib v0.15.0/go.mod h1:lei+qt5CLB4oa7VHTE0yEfQSEB9XTJI1LUqko9UWvo4=
108+github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
109+github.com/labstack/gommon v0.4.1/go.mod h1:TyTrpPqxR5KMk8LKVtLmfMjeQ5FEkBYdxLYPw/WfrOM=
110+github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
111+github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
112+github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
113+github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
114+github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ=
115+github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
116 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
117 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
118+github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
119+github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
120 github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE=
121 github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo=
122+github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
123 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
124 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
125+github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
126+github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
127+github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
128+github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
129+github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
130+github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k=
131+github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
132+github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
133+github.com/opensearch-project/opensearch-go/v2 v2.3.0/go.mod h1:8LDr9FCgUTVoT+5ESjc2+iaZuldqE+23Iq0r1XeNue8=
134+github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
135+github.com/orandin/slog-gorm v1.3.2/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
136+github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw=
137+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
138 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
139+github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
140+github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
141+github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
142+github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
143+github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
144+github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
145+github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
146+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
147 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
148 github.com/pterm/pterm v0.12.82/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw=
149+github.com/puzpuzpuz/xsync/v3 v3.0.2/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
150+github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
151+github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
152 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
153+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
154 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
155 github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
156+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
157+github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
158+github.com/samber/slog-echo v1.8.0/go.mod h1:0ab2AwcciQXNAXEcjkHwD9okOh9vEHEYn8xP97ocuhM=
159 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
160 github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
161 github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
162@@ -64,20 +173,42 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
163 github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
164 github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
165 github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s=
166+github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
167+github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
168+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
169+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
170 github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4=
171+github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q=
172+github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
173+github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
174+github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ=
175+github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
176+github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
177 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
178+github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
179 github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
180 github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1/go.mod h1:l5sSv153E18VvYcsmr51hok9Sjc16tEC8AXGbwrk+ho=
181 github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
182+gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
183+gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
184+gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
185+gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
186 go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
187+go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.45.0/go.mod h1:Px9kH7SJ+NhsgWRtD/eMcs15Tyt4uL3rM7X54qv6pfA=
188+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
189+go.opentelemetry.io/otel/exporters/jaeger v1.14.0/go.mod h1:4Ay9kk5vELRrbg5z4cpP9EtmQRFap2Wb0woPG4lujZA=
190+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0/go.mod h1:zgBdWWAu7oEEMC06MMKc5NLbA/1YDXV1sMpSqEeLQLg=
191+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I=
192+go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
193+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
194+go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
195+go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
196 golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
197 golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
198-golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
199-golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
200+golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
201 golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
202 golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
203-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
204-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
205+golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
206 golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
207 golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
208 golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
209@@ -86,14 +217,20 @@ golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
210 golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
211 golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
212 golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
213-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
214+golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
215+golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
216+golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
217 golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
218-golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
219-golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
220 golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
221 golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
222 google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
223+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
224+gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
225+gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
226+gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
227+gorm.io/plugin/opentelemetry v0.1.3/go.mod h1:tndJHOdvPT0pyGhOb8E2209eXJCUxhC5UpKw7bGVWeI=
228 howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
229+lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
230 modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
231 modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
232 modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
A server/atproto/store.go
+127, -0
  1@@ -0,0 +1,127 @@
  2+package atproto
  3+
  4+import (
  5+	"context"
  6+	"database/sql"
  7+	"encoding/json"
  8+	"fmt"
  9+	"time"
 10+
 11+	"github.com/bluesky-social/indigo/atproto/auth/oauth"
 12+	"github.com/bluesky-social/indigo/atproto/syntax"
 13+)
 14+
 15+const (
 16+	SQLITE_TIMEFMT = "2006-01-02 15:04:05"
 17+)
 18+
 19+type Store struct {
 20+	db *sql.DB
 21+}
 22+
 23+func NewOauthStore(db *sql.DB) (*Store, error) {
 24+	if err := migrate(db); err != nil {
 25+		return nil, fmt.Errorf("failed to migrate oauth store: %w", err)
 26+	}
 27+	return &Store{db}, nil
 28+}
 29+
 30+// Migrate store database (Sqlite)
 31+func migrate(db *sql.DB) error {
 32+	s := `CREATE TABLE IF NOT EXISTS
 33+        oauth_session (
 34+			id INTEGER PRIMARY KEY AUTOINCREMENT,
 35+			did TEXT NOT NULL,
 36+			session_id TEXT NOT NULL,
 37+			client_session_data BLOB NOT NULL,
 38+			mtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP
 39+        );
 40+		CREATE TABLE IF NOT EXISTS
 41+		oauth_request (
 42+			id INTEGER PRIMARY KEY AUTOINCREMENT,
 43+			state TEXT NOT NULL,
 44+			auth_request_data BLOB NOT NULL,
 45+			mtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP
 46+		);
 47+		CREATE UNIQUE INDEX IF NOT EXISTS did_session_idx ON oauth_session(did, session_id);
 48+		CREATE UNIQUE INDEX IF NOT EXISTS state_idx ON oauth_request(state);
 49+		CREATE INDEX IF NOT EXISTS session_mtime_idx ON oauth_session(mtime);
 50+		CREATE INDEX IF NOT EXISTS request_mtime_idx ON oauth_request(mtime);
 51+		CREATE TRIGGER IF NOT EXISTS delete_expired_oauth_sessions AFTER INSERT ON oauth_session
 52+		BEGIN
 53+			DELETE FROM oauth_session WHERE mtime < datetime('now', '-6 months');
 54+		END;
 55+		CREATE TRIGGER IF NOT EXISTS delete_expired_oauth_requests AFTER INSERT ON oauth_request
 56+		BEGIN
 57+			DELETE FROM oauth_request WHERE mtime < datetime('now', '-6 months');
 58+		END;
 59+		`
 60+	if _, err := db.Exec(s); err != nil {
 61+		return fmt.Errorf("failed to migrate sessions database: %w", err)
 62+	}
 63+	return nil
 64+}
 65+
 66+func (s *Store) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
 67+	var blob []byte
 68+	if err := s.db.QueryRowContext(ctx, "SELECT client_session_data FROM oauth_session WHERE did = ? AND session_id = ?", did.String(), sessionID).Scan(&blob); err != nil {
 69+		return nil, fmt.Errorf("failed to get session: %w", err)
 70+	}
 71+	var sessionData oauth.ClientSessionData
 72+	if err := json.Unmarshal(blob, &sessionData); err != nil {
 73+		return nil, fmt.Errorf("failed to unmarshal session: %w", err)
 74+	}
 75+	return &sessionData, nil
 76+}
 77+
 78+func (s *Store) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
 79+	blob, err := json.Marshal(sess)
 80+	if err != nil {
 81+		return fmt.Errorf("error marshaling client session data: %w", err)
 82+	}
 83+	if _, err := s.db.ExecContext(ctx, "INSERT INTO oauth_session (client_session_data, did, session_id, mtime) VALUES (?, ?, ?, ?) ON CONFLICT (did, session_id) DO UPDATE SET client_session_data=excluded.client_session_data, mtime=excluded.mtime", blob, sess.AccountDID.String(), sess.SessionID, time.Now().UTC().Format(SQLITE_TIMEFMT)); err != nil {
 84+		return fmt.Errorf("failed in save session: %w", err)
 85+	}
 86+	return nil
 87+}
 88+
 89+func (s *Store) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
 90+	if _, err := s.db.ExecContext(ctx, "DELETE FROM oauth_session WHERE did=? AND session_id=?", did.String(), sessionID); err != nil {
 91+		return fmt.Errorf("failed to delete session: %w", err)
 92+	}
 93+	return nil
 94+}
 95+
 96+func (s *Store) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
 97+	var blob []byte
 98+	if err := s.db.QueryRowContext(ctx, "SELECT auth_request_data FROM oauth_request WHERE state = ?", state).Scan(&blob); err != nil {
 99+		return nil, fmt.Errorf("failed to get auth request info: %w", err)
100+	}
101+	var requestData oauth.AuthRequestData
102+	if err := json.Unmarshal(blob, &requestData); err != nil {
103+		return nil, fmt.Errorf("failed to unmarshal auth request: %w", err)
104+	}
105+	return &requestData, nil
106+}
107+
108+func (s *Store) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
109+	blob, err := json.Marshal(info)
110+	if err != nil {
111+		return fmt.Errorf("error marshaling oauth request data: %w", err)
112+	}
113+	if _, err := s.db.ExecContext(ctx, "INSERT INTO oauth_request (auth_request_data, state, mtime) VALUES (?, ?, ?)", blob, info.State, time.Now().UTC().Format(SQLITE_TIMEFMT)); err != nil {
114+		return fmt.Errorf("failed to save oauth request: %w", err)
115+	}
116+	return nil
117+}
118+
119+func (s *Store) DeleteAuthRequestInfo(ctx context.Context, state string) error {
120+	if _, err := s.db.ExecContext(ctx, "DELETE FROM oauth_request WHERE state=?", state); err != nil {
121+		return fmt.Errorf("failed to delete oauth request: %w", err)
122+	}
123+	return nil
124+}
125+
126+// implements ClientAuthStore for ATProto oauth
127+// See: https://github.com/bluesky-social/indigo/blob/main/atproto/auth/oauth/store.go
128+var _ oauth.ClientAuthStore = &Store{}
A server/atproto/store_test.go
+298, -0
  1@@ -0,0 +1,298 @@
  2+package atproto
  3+
  4+import (
  5+	"context"
  6+	"database/sql"
  7+	"testing"
  8+	"time"
  9+
 10+	"github.com/bluesky-social/indigo/atproto/auth/oauth"
 11+	"github.com/bluesky-social/indigo/atproto/syntax"
 12+	"github.com/stretchr/testify/assert"
 13+	_ "github.com/mattn/go-sqlite3"
 14+)
 15+
 16+func makeStore(t *testing.T) (*Store, func()) {
 17+	t.Helper()
 18+	db, err := sql.Open("sqlite3", "file:test.db?cache=shared&mode=memory&_fk=false")
 19+	if err != nil {
 20+		t.Fatalf("failed to open db: %v", err)
 21+	}
 22+	store, err := NewOauthStore(db)
 23+	if err != nil {
 24+		t.Fatalf("failed to create store: %v", err)
 25+	}
 26+	return store, func() { _ = db.Close() }
 27+}
 28+
 29+func testDID() syntax.DID {
 30+	did, _ := syntax.ParseDID("did:plc:1234567890abcdefghij")
 31+	return did
 32+}
 33+
 34+func testSessionData(did syntax.DID, sessionID string) oauth.ClientSessionData {
 35+	return oauth.ClientSessionData{
 36+		AccountDID:                   did,
 37+		SessionID:                    sessionID,
 38+		HostURL:                      "https://example.com",
 39+		AuthServerURL:                "https://auth.example.com",
 40+		AuthServerTokenEndpoint:      "https://auth.example.com/token",
 41+		AuthServerRevocationEndpoint: "https://auth.example.com/revoke",
 42+		Scopes:                       []string{"atproto"},
 43+		AccessToken:                  "access-token-1",
 44+		RefreshToken:                 "refresh-token-1",
 45+		DPoPAuthServerNonce:         "nonce-auth-1",
 46+		DPoPHostNonce:                "nonce-host-1",
 47+		DPoPPrivateKeyMultibase:      "zDnaaBxv5C8R7R2Nn9Q3eV4F1G6HbJkLm0PsTtUuVwXyZ",
 48+	}
 49+}
 50+
 51+func testAuthRequestData(state string) oauth.AuthRequestData {
 52+	return oauth.AuthRequestData{
 53+		State:                   state,
 54+		AuthServerURL:           "https://auth.example.com",
 55+		Scopes:                  []string{"atproto"},
 56+		PKCEVerifier:            "pkce-verifier-1",
 57+		RequestURI:              "urn:ietf:params:oauth:request_uri:abc123",
 58+		AuthServerTokenEndpoint: "https://auth.example.com/token",
 59+		DPoPAuthServerNonce:     "nonce-auth-1",
 60+		DPoPPrivateKeyMultibase: "zDnaaBxv5C8R7R2Nn9Q3eV4F1G6HbJkLm0PsTtUuVwXyZ",
 61+	}
 62+}
 63+
 64+func TestSaveAndGetSession(t *testing.T) {
 65+	store, cleanup := makeStore(t)
 66+	defer cleanup()
 67+	ctx := context.Background()
 68+	did := testDID()
 69+	sess := testSessionData(did, "session-1")
 70+
 71+	assert.NoError(t, store.SaveSession(ctx, sess))
 72+
 73+	got, err := store.GetSession(ctx, did, "session-1")
 74+	assert.NoError(t, err)
 75+	assert.Equal(t, sess.AccountDID.String(), got.AccountDID.String())
 76+	assert.Equal(t, sess.SessionID, got.SessionID)
 77+	assert.Equal(t, sess.AccessToken, got.AccessToken)
 78+	assert.Equal(t, sess.RefreshToken, got.RefreshToken)
 79+	assert.Equal(t, sess.Scopes, got.Scopes)
 80+	assert.Equal(t, sess.DPoPPrivateKeyMultibase, got.DPoPPrivateKeyMultibase)
 81+}
 82+
 83+func TestSaveSessionUpsert(t *testing.T) {
 84+	store, cleanup := makeStore(t)
 85+	defer cleanup()
 86+	ctx := context.Background()
 87+	did := testDID()
 88+
 89+	sess1 := testSessionData(did, "session-1")
 90+	sess1.AccessToken = "access-token-1"
 91+	sess1.RefreshToken = "refresh-token-1"
 92+	assert.NoError(t, store.SaveSession(ctx, sess1))
 93+
 94+	sess2 := testSessionData(did, "session-1")
 95+	sess2.AccessToken = "access-token-2"
 96+	sess2.RefreshToken = "refresh-token-2"
 97+	assert.NoError(t, store.SaveSession(ctx, sess2))
 98+
 99+	var count int
100+	err := store.db.QueryRow("SELECT COUNT(*) FROM oauth_session WHERE did=? AND session_id=?", did.String(), "session-1").Scan(&count)
101+	assert.NoError(t, err)
102+	assert.Equal(t, 1, count, "upsert should not create duplicate rows")
103+
104+	got, err := store.GetSession(ctx, did, "session-1")
105+	assert.NoError(t, err)
106+	assert.Equal(t, "access-token-2", got.AccessToken)
107+	assert.Equal(t, "refresh-token-2", got.RefreshToken)
108+
109+	var mtime string
110+	err = store.db.QueryRow("SELECT mtime FROM oauth_session WHERE did=? AND session_id=?", did.String(), "session-1").Scan(&mtime)
111+	assert.NoError(t, err)
112+	assert.NotEmpty(t, mtime)
113+}
114+
115+func TestGetSessionNotFound(t *testing.T) {
116+	store, cleanup := makeStore(t)
117+	defer cleanup()
118+	ctx := context.Background()
119+	did := testDID()
120+
121+	_, err := store.GetSession(ctx, did, "nonexistent-session")
122+	assert.Error(t, err)
123+	assert.ErrorIs(t, err, sql.ErrNoRows)
124+}
125+
126+func TestDeleteSession(t *testing.T) {
127+	store, cleanup := makeStore(t)
128+	defer cleanup()
129+	ctx := context.Background()
130+	did := testDID()
131+	sess := testSessionData(did, "session-1")
132+	assert.NoError(t, store.SaveSession(ctx, sess))
133+
134+	assert.NoError(t, store.DeleteSession(ctx, did, "session-1"))
135+
136+	_, err := store.GetSession(ctx, did, "session-1")
137+	assert.Error(t, err)
138+}
139+
140+func TestMultipleSessionsForSameDID(t *testing.T) {
141+	store, cleanup := makeStore(t)
142+	defer cleanup()
143+	ctx := context.Background()
144+	did := testDID()
145+
146+	sess1 := testSessionData(did, "session-1")
147+	sess1.AccessToken = "token-1"
148+	sess2 := testSessionData(did, "session-2")
149+	sess2.AccessToken = "token-2"
150+
151+	assert.NoError(t, store.SaveSession(ctx, sess1))
152+	assert.NoError(t, store.SaveSession(ctx, sess2))
153+
154+	got1, err := store.GetSession(ctx, did, "session-1")
155+	assert.NoError(t, err)
156+	assert.Equal(t, "token-1", got1.AccessToken)
157+
158+	got2, err := store.GetSession(ctx, did, "session-2")
159+	assert.NoError(t, err)
160+	assert.Equal(t, "token-2", got2.AccessToken)
161+
162+	assert.NoError(t, store.DeleteSession(ctx, did, "session-1"))
163+
164+	_, err = store.GetSession(ctx, did, "session-1")
165+	assert.Error(t, err)
166+
167+	got2After, err := store.GetSession(ctx, did, "session-2")
168+	assert.NoError(t, err)
169+	assert.Equal(t, "token-2", got2After.AccessToken)
170+}
171+
172+func TestSaveAndGetAuthRequestInfo(t *testing.T) {
173+	store, cleanup := makeStore(t)
174+	defer cleanup()
175+	ctx := context.Background()
176+	info := testAuthRequestData("state-123")
177+
178+	assert.NoError(t, store.SaveAuthRequestInfo(ctx, info))
179+
180+	got, err := store.GetAuthRequestInfo(ctx, "state-123")
181+	assert.NoError(t, err)
182+	assert.Equal(t, info.State, got.State)
183+	assert.Equal(t, info.AuthServerURL, got.AuthServerURL)
184+	assert.Equal(t, info.PKCEVerifier, got.PKCEVerifier)
185+	assert.Equal(t, info.RequestURI, got.RequestURI)
186+	assert.Equal(t, info.DPoPAuthServerNonce, got.DPoPAuthServerNonce)
187+	assert.Equal(t, info.DPoPPrivateKeyMultibase, got.DPoPPrivateKeyMultibase)
188+	assert.Equal(t, info.Scopes, got.Scopes)
189+}
190+
191+func TestSaveAuthRequestInfoCreateOnly(t *testing.T) {
192+	store, cleanup := makeStore(t)
193+	defer cleanup()
194+	ctx := context.Background()
195+	info := testAuthRequestData("state-123")
196+	assert.NoError(t, store.SaveAuthRequestInfo(ctx, info))
197+
198+	dup := testAuthRequestData("state-123")
199+	dup.PKCEVerifier = "different-verifier"
200+	err := store.SaveAuthRequestInfo(ctx, dup)
201+	assert.Error(t, err)
202+
203+	got, err := store.GetAuthRequestInfo(ctx, "state-123")
204+	assert.NoError(t, err)
205+	assert.Equal(t, "pkce-verifier-1", got.PKCEVerifier)
206+}
207+
208+func TestGetAuthRequestInfoNotFound(t *testing.T) {
209+	store, cleanup := makeStore(t)
210+	defer cleanup()
211+	ctx := context.Background()
212+
213+	_, err := store.GetAuthRequestInfo(ctx, "nonexistent-state")
214+	assert.Error(t, err)
215+	assert.ErrorIs(t, err, sql.ErrNoRows)
216+}
217+
218+func TestDeleteAuthRequestInfo(t *testing.T) {
219+	store, cleanup := makeStore(t)
220+	defer cleanup()
221+	ctx := context.Background()
222+	info := testAuthRequestData("state-123")
223+	assert.NoError(t, store.SaveAuthRequestInfo(ctx, info))
224+
225+	assert.NoError(t, store.DeleteAuthRequestInfo(ctx, "state-123"))
226+
227+	_, err := store.GetAuthRequestInfo(ctx, "state-123")
228+	assert.Error(t, err)
229+}
230+
231+func TestMigrateIdempotent(t *testing.T) {
232+	db, err := sql.Open("sqlite3", "file:test.db?cache=shared&mode=memory&_fk=false")
233+	assert.NoError(t, err)
234+	defer func() { _ = db.Close() }()
235+
236+	assert.NoError(t, migrate(db))
237+	assert.NoError(t, migrate(db))
238+
239+	store := &Store{db: db}
240+	ctx := context.Background()
241+	did := testDID()
242+	sess := testSessionData(did, "session-1")
243+	assert.NoError(t, store.SaveSession(ctx, sess))
244+	got, err := store.GetSession(ctx, did, "session-1")
245+	assert.NoError(t, err)
246+	assert.Equal(t, sess.AccessToken, got.AccessToken)
247+}
248+
249+func TestGCExpiredSessions(t *testing.T) {
250+	store, cleanup := makeStore(t)
251+	defer cleanup()
252+	ctx := context.Background()
253+	did := testDID()
254+
255+	sess := testSessionData(did, "old-session")
256+	assert.NoError(t, store.SaveSession(ctx, sess))
257+	_, err := store.db.Exec("UPDATE oauth_session SET mtime=? WHERE did=? AND session_id=?",
258+		time.Now().UTC().Add(-7*30*24*time.Hour).Format(SQLITE_TIMEFMT),
259+		did.String(), "old-session")
260+	assert.NoError(t, err)
261+
262+	freshSess := testSessionData(did, "fresh-session")
263+	assert.NoError(t, store.SaveSession(ctx, freshSess))
264+
265+	var count int
266+	err = store.db.QueryRow("SELECT COUNT(*) FROM oauth_session WHERE session_id=?", "old-session").Scan(&count)
267+	assert.NoError(t, err)
268+	assert.Equal(t, 0, count, "expired session should be deleted by GC trigger")
269+
270+	err = store.db.QueryRow("SELECT COUNT(*) FROM oauth_session WHERE session_id=?", "fresh-session").Scan(&count)
271+	assert.NoError(t, err)
272+	assert.Equal(t, 1, count, "fresh session should not be deleted")
273+}
274+
275+func TestGCExpiredAuthRequests(t *testing.T) {
276+	store, cleanup := makeStore(t)
277+	defer cleanup()
278+	ctx := context.Background()
279+
280+	info := testAuthRequestData("old-state")
281+	assert.NoError(t, store.SaveAuthRequestInfo(ctx, info))
282+	_, err := store.db.Exec("UPDATE oauth_request SET mtime=? WHERE state=?",
283+		time.Now().UTC().Add(-7*30*24*time.Hour).Format(SQLITE_TIMEFMT),
284+		"old-state")
285+	assert.NoError(t, err)
286+
287+	freshInfo := testAuthRequestData("fresh-state")
288+	assert.NoError(t, store.SaveAuthRequestInfo(ctx, freshInfo))
289+
290+	var count int
291+	err = store.db.QueryRow("SELECT COUNT(*) FROM oauth_request WHERE state=?", "old-state").Scan(&count)
292+	assert.NoError(t, err)
293+	assert.Equal(t, 0, count, "expired auth request should be deleted by GC trigger")
294+
295+	err = store.db.QueryRow("SELECT COUNT(*) FROM oauth_request WHERE state=?", "fresh-state").Scan(&count)
296+	assert.NoError(t, err)
297+	assert.Equal(t, 1, count, "fresh auth request should not be deleted")
298+}
299+
M server/go.mod
+1, -0
1@@ -23,6 +23,7 @@ require (
2 	github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
3 	github.com/a-h/templ v0.3.1020 // indirect
4 	github.com/andybalholm/brotli v1.1.1 // indirect
5+	github.com/bluesky-social/indigo v0.0.0-20260611225325-2e8287f2f1bb // indirect
6 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
7 	github.com/cli/browser v1.3.0 // indirect
8 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
M server/go.sum
+3, -0
 1@@ -16,6 +16,8 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7X
 2 github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
 3 github.com/bits-and-blooms/bloom v2.0.3+incompatible h1:3ONZFjJoMyfHDil5iCcNkcPJ//PNNo+55RHvPrfUGnY=
 4 github.com/bits-and-blooms/bloom v2.0.3+incompatible/go.mod h1:nEmPH2pqJb3sCXfd7cyDSKC4iPfCAt312JHgNrtnnDE=
 5+github.com/bluesky-social/indigo v0.0.0-20260611225325-2e8287f2f1bb h1:KeRCiwcu3KYQShMFq21KgAnYVFe4XDe9O+xhQL7Bch4=
 6+github.com/bluesky-social/indigo v0.0.0-20260611225325-2e8287f2f1bb/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
 7 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
 8 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 9 github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
10@@ -50,6 +52,7 @@ github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+
11 github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
12 github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
13 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
14+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
15 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
16 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
17 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
M server/pkg/session/db.go
+1, -1
1@@ -25,7 +25,7 @@ func Migrate(db *sql.DB) error {
2 			ON DELETE CASCADE
3 		);
4 	CREATE UNIQUE INDEX IF NOT EXISTS session_idx ON sessions(session_hash);
5-	CREATE TRIGGER IF NOT EXISTS delete_expired AFTER INSERT ON sessions
6+	CREATE TRIGGER IF NOT EXISTS delete_expired_sessions AFTER INSERT ON sessions
7 	BEGIN
8 		DELETE FROM sessions WHERE expires < datetime('now');
9 	END`