diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b94985 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Niels Mokkenstorm + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/atproto/LICENSE b/atproto/LICENSE new file mode 100644 index 0000000..4b94985 --- /dev/null +++ b/atproto/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Niels Mokkenstorm + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/atproto/gleam.toml b/atproto/gleam.toml index ace410b..f41c144 100644 --- a/atproto/gleam.toml +++ b/atproto/gleam.toml @@ -1,6 +1,7 @@ name = "atproto" version = "0.1.0" description = "A small, transport-agnostic atproto client for Gleam: XRPC, identity, auth, and repo CRUD. No app or lexicon knowledge." +licences = ["MIT"] [dependencies] gleam_stdlib = ">= 1.0.0 and < 2.0.0" diff --git a/atproto/src/atproto/auth.gleam b/atproto/src/atproto/auth.gleam index eb21fe8..deab91b 100644 --- a/atproto/src/atproto/auth.gleam +++ b/atproto/src/atproto/auth.gleam @@ -1,10 +1,11 @@ //// atproto session auth: exchange an identifier + password for session tokens -//// via `com.atproto.server.createSession`. +//// via `com.atproto.server.createSession`, and refresh them via +//// `com.atproto.server.refreshSession`. import atproto/xrpc.{type Client, type XrpcError} import gleam/dynamic/decode import gleam/json -import gleam/option.{None} +import gleam/option.{None, Some} import gleam/result pub type SessionTokens { @@ -33,12 +34,29 @@ pub fn create_session( None, body, )) - let decoder = { - use did <- decode.field("did", decode.string) - use handle <- decode.field("handle", decode.string) - use access_jwt <- decode.field("accessJwt", decode.string) - use refresh_jwt <- decode.field("refreshJwt", decode.string) - decode.success(SessionTokens(did:, handle:, access_jwt:, refresh_jwt:)) - } - xrpc.parse(resp.body, decoder) + xrpc.parse(resp.body, tokens_decoder()) +} + +/// Exchange a refresh JWT for a fresh session (the refresh token is sent as the +/// bearer credential, per `com.atproto.server.refreshSession`). +pub fn refresh_session( + client: Client, + pds: String, + refresh_jwt: String, +) -> Result(SessionTokens, XrpcError) { + use resp <- result.try(xrpc.post_json( + client, + pds <> "/xrpc/com.atproto.server.refreshSession", + Some(refresh_jwt), + json.object([]), + )) + xrpc.parse(resp.body, tokens_decoder()) +} + +fn tokens_decoder() -> decode.Decoder(SessionTokens) { + use did <- decode.field("did", decode.string) + use handle <- decode.field("handle", decode.string) + use access_jwt <- decode.field("accessJwt", decode.string) + use refresh_jwt <- decode.field("refreshJwt", decode.string) + decode.success(SessionTokens(did:, handle:, access_jwt:, refresh_jwt:)) } diff --git a/atproto/src/atproto/identity.gleam b/atproto/src/atproto/identity.gleam index 0f9362f..2487c8c 100644 --- a/atproto/src/atproto/identity.gleam +++ b/atproto/src/atproto/identity.gleam @@ -1,11 +1,17 @@ -//// Resolve a handle or DID to its PDS endpoint via a resolver implementing -//// Slingshot's resolveMiniDoc (a one-call did + handle + pds summary). The -//// resolver base URL is injected rather than hardcoded to a single service. +//// Resolve a handle or DID to its PDS endpoint. +//// +//// NOTE: this delegates to a resolver implementing Slingshot's `resolveMiniDoc` +//// (a one-call did + handle + pds summary), NOT to native atproto identity +//// resolution (DID doc / PLC directory / did:web / DNS handle). The resolver +//// base URL is injected, so you can point it at any compatible service, but a +//// consumer depends on such a service being available. A native resolver is a +//// future addition. import atproto/xrpc.{type Client, type XrpcError} import gleam/dynamic/decode import gleam/option.{None} import gleam/result +import gleam/uri pub const default_resolver = "https://slingshot.microcosm.blue" @@ -14,10 +20,9 @@ pub fn resolve_pds( resolver: String, identifier: String, ) -> Result(String, XrpcError) { + let query = uri.query_to_string([#("identifier", identifier)]) let url = - resolver - <> "/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" - <> identifier + resolver <> "/xrpc/com.bad-example.identity.resolveMiniDoc?" <> query use resp <- result.try(xrpc.get(client, url, None)) xrpc.parse(resp.body, decode.at(["pds"], decode.string)) } diff --git a/atproto/src/atproto/repo.gleam b/atproto/src/atproto/repo.gleam index 23396bd..386981b 100644 --- a/atproto/src/atproto/repo.gleam +++ b/atproto/src/atproto/repo.gleam @@ -4,13 +4,17 @@ import atproto/xrpc.{type Client, type XrpcError} import gleam/dynamic/decode import gleam/json.{type Json} -import gleam/option.{Some} +import gleam/list +import gleam/option.{type Option, None, Some} import gleam/result +import gleam/uri pub type CreatedRecord { CreatedRecord(uri: String, cid: String) } +/// List every record in a collection, following the cursor across pages (the +/// XRPC endpoint caps each page at 100). Returns the full set. pub fn list_records( client: Client, pds: String, @@ -19,19 +23,43 @@ pub fn list_records( collection: String, row_decoder: decode.Decoder(a), ) -> Result(List(a), XrpcError) { + list_page(client, pds, token, did, collection, row_decoder, None, []) +} + +fn list_page( + client: Client, + pds: String, + token: String, + did: String, + collection: String, + row_decoder: decode.Decoder(a), + cursor: Option(String), + acc: List(a), +) -> Result(List(a), XrpcError) { + let base = [#("repo", did), #("collection", collection), #("limit", "100")] + let params = case cursor { + Some(c) -> [#("cursor", c), ..base] + None -> base + } let url = - pds - <> "/xrpc/com.atproto.repo.listRecords?repo=" - <> did - <> "&collection=" - <> collection - <> "&limit=100" + pds <> "/xrpc/com.atproto.repo.listRecords?" <> uri.query_to_string(params) use resp <- result.try(xrpc.get(client, url, Some(token))) - let records = { + let page = { use rows <- decode.field("records", decode.list(row_decoder)) - decode.success(rows) + use next <- decode.optional_field( + "cursor", + None, + decode.optional(decode.string), + ) + decode.success(#(rows, next)) + } + use #(rows, next) <- result.try(xrpc.parse(resp.body, page)) + let all = list.append(acc, rows) + case next { + Some("") | None -> Ok(all) + Some(c) -> + list_page(client, pds, token, did, collection, row_decoder, Some(c), all) } - xrpc.parse(resp.body, records) } pub fn create_record( diff --git a/atproto/src/atproto/xrpc.gleam b/atproto/src/atproto/xrpc.gleam index 043100e..6e6586e 100644 --- a/atproto/src/atproto/xrpc.gleam +++ b/atproto/src/atproto/xrpc.gleam @@ -17,7 +17,15 @@ pub type Client { pub type XrpcError { RequestFailed(String) - BadStatus(Int, String) + /// A non-2xx response. atproto error bodies are JSON `{error, message}`; both + /// are parsed out (when present) so callers can branch on `error` (e.g. + /// `ExpiredToken`) instead of string-matching the raw body. + BadStatus( + status: Int, + error: Option(String), + message: Option(String), + body: String, + ) DecodeFailed(String) } @@ -70,6 +78,26 @@ fn with_auth(req: Request(String), token: Option(String)) -> Request(String) { fn check_ok(resp: Response(String)) -> Result(Response(String), XrpcError) { case resp.status >= 200 && resp.status < 300 { True -> Ok(resp) - False -> Error(BadStatus(resp.status, resp.body)) + False -> { + let #(error, message) = parse_error(resp.body) + Error(BadStatus(resp.status, error, message, resp.body)) + } + } +} + +fn parse_error(body: String) -> #(Option(String), Option(String)) { + let decoder = { + use error <- decode.optional_field( + "error", + None, + decode.optional(decode.string), + ) + use message <- decode.optional_field( + "message", + None, + decode.optional(decode.string), + ) + decode.success(#(error, message)) } + json.parse(body, decoder) |> result.unwrap(#(None, None)) } diff --git a/atproto/test/client_test.gleam b/atproto/test/client_test.gleam new file mode 100644 index 0000000..2bf9cdf --- /dev/null +++ b/atproto/test/client_test.gleam @@ -0,0 +1,54 @@ +import atproto/repo +import atproto/xrpc +import gleam/dynamic/decode +import gleam/http/response.{Response} +import gleam/option.{None, Some} +import gleam/string + +fn uri_decoder() -> decode.Decoder(String) { + use uri <- decode.field("uri", decode.string) + decode.success(uri) +} + +const page_one = "{\"records\":[{\"uri\":\"at://1\"},{\"uri\":\"at://2\"}],\"cursor\":\"c1\"}" + +const page_two = "{\"records\":[{\"uri\":\"at://3\"}]}" + +fn paged_client() -> xrpc.Client { + xrpc.Client(send: fn(req) { + let body = case string.contains(option.unwrap(req.query, ""), "cursor=") { + True -> page_two + False -> page_one + } + Ok(Response(200, [], body)) + }) +} + +pub fn list_records_follows_cursor_across_pages_test() { + let assert Ok(rows) = + repo.list_records( + paged_client(), + "https://pds.example", + "token", + "did:plc:abc", + "app.example.thing", + uri_decoder(), + ) + assert rows == ["at://1", "at://2", "at://3"] +} + +pub fn bad_status_parses_atproto_error_body_test() { + let client = + xrpc.Client(send: fn(_req) { + Ok(Response( + 400, + [], + "{\"error\":\"InvalidRequest\",\"message\":\"nope\"}", + )) + }) + let assert Error(xrpc.BadStatus(status:, error:, message:, ..)) = + xrpc.get(client, "https://pds.example/xrpc/x", None) + assert status == 400 + assert error == Some("InvalidRequest") + assert message == Some("nope") +} diff --git a/codegen/gleam.toml b/codegen/gleam.toml index 1409ddb..04db207 100644 --- a/codegen/gleam.toml +++ b/codegen/gleam.toml @@ -1,6 +1,7 @@ name = "codegen" version = "1.0.0" description = "Generates Gleam lexicon codecs from lexicons/ into the shared package." +licences = ["MIT"] [dependencies] gleam_stdlib = ">= 1.0.0 and < 2.0.0" diff --git a/server/src/at_record_server/oauth/tokens.gleam b/server/src/at_record_server/oauth/tokens.gleam index 152e873..77d15d3 100644 --- a/server/src/at_record_server/oauth/tokens.gleam +++ b/server/src/at_record_server/oauth/tokens.gleam @@ -115,6 +115,6 @@ fn describe(e: xrpc.XrpcError) -> String { case e { xrpc.DecodeFailed(m) -> "decode token response: " <> m xrpc.RequestFailed(m) -> m - xrpc.BadStatus(s, m) -> int.to_string(s) <> ": " <> m + xrpc.BadStatus(status:, body:, ..) -> int.to_string(status) <> ": " <> body } } diff --git a/shared/gleam.toml b/shared/gleam.toml index 9d86768..1a5996c 100644 --- a/shared/gleam.toml +++ b/shared/gleam.toml @@ -1,6 +1,7 @@ name = "at_record_shared" version = "1.0.0" description = "Lexicon types and JSON codecs shared by the at-record server and web apps." +licences = ["MIT"] [dependencies] gleam_stdlib = ">= 1.0.0 and < 2.0.0"