ATProto for TCA applications in Swift
0

Configure Feed

Select the types of activity you want to include in your feed.

Swift 100.0%
46 6 0

Clone this repository

https://tangled.org/woody.fm/swift-composable-atmosphere https://tangled.org/did:plc:eufmyldm4rpklxvboac2rbtp
git@tangled.org:woody.fm/swift-composable-atmosphere git@tangled.org:did:plc:eufmyldm4rpklxvboac2rbtp

For self-hosted knots, clone URLs may differ based on your setup.



README.md

Composable Atmosphere#

Type-safe, testable Swift tools for building AT Protocol clients, OAuth flows, and Composable Architecture applications.

WARNING

Composable Atmosphere is under active development. The package is already useful for AT Protocol identity, XRPC, OAuth, and app-auth experiments, but the public API is still settling.

Overview#

Composable Atmosphere brings Point-Free-style Swift ergonomics to the AT Protocol:

  • Core AT Protocol identifiers such as DIDs, handles, AT URIs, NSIDs, CIDs, blobs, and PDS hosts are modeled as small Swift types instead of unstructured strings.
  • Network clients are dependency clients, so identity resolution, XRPC calls, OAuth, and auth presentation can be controlled in tests and previews with withDependencies.
  • XRPC methods are represented as request values that declare their NSID, request type, input, parameters, output, routing, and authorization policy.
  • OAuth support handles the pieces AT Protocol clients need in practice: handle/DID resolution, authorization-server discovery, PAR, PKCE, DPoP-bound tokens, callback handling, refresh, and permission escalation.
  • The TCA integration wraps OAuth session state around authenticated features, installs default session dependencies, and exposes login/logout/session events to the feature tree.

The goal is not to hide the AT Protocol. It is to make protocol work feel like ordinary Swift app work: explicit values, typed boundaries, controllable dependencies, and focused tests.

Quick start#

Define an XRPC request by conforming a small value to XRPC.Request:

import ATProto
import XRPCClient

struct GetPosts: XRPC.Request {
  typealias Input = Never

  static let nsid: ATProto.NSID = "app.bsky.feed.getPosts"
  static let type: XRPC.RequestType = .query

  struct Parameters: Encodable {
    var uris: [ATProto.URI]
  }

  struct Output: Decodable {
    var posts: [Post]
  }

  var parameters: Parameters
}

struct Post: Decodable {
  var uri: ATProto.URI
  var author: Author

  struct Author: Decodable {
    var did: DID
  }
}

Run it through the dependency-controlled XRPC client:

import Dependencies

let response = try await withDependencies {
  $0.pdsHost = "public.api.bsky.app"
} operation: {
  @Dependency(XRPC.Client.self) var xrpcClient

  return try await xrpcClient.run(
    GetPosts(
      parameters: .init(
        uris: [
          ATProto.URI(
            "at://did:plc:cqqtuv75m7e5s4uj6nw4czyu/app.bsky.feed.post/3mijxhhak2s2l"
          )!
        ]
      )
    )
  )
}

print(response.posts)

For authenticated methods, declare an authorization policy on the request:

struct CreatePost: XRPC.Request {
  static let nsid: ATProto.NSID = "com.atproto.repo.createRecord"
  static let type: XRPC.RequestType = .procedure

  static let authorizationPolicy: XRPC.AuthorizationPolicy = .required(
    ATProto.OAuth.AuthRequirement(
      permissions: [
        .repo("app.bsky.feed.post", actions: [.create])
      ],
      reason: "Create posts"
    )
  )

  // Input, Parameters, Output, and stored properties...
}

The live XRPC client asks the xrpcAuthorizationGate dependency how to satisfy that policy, which lets apps decide whether to run unauthenticated, attach the current OAuth session, prompt for sign-in, or request additional permissions.

Products#

The package is split into focused products so apps can adopt the pieces they need:

Product Purpose
ATProto Core identifiers, DID documents, handle resolution, blobs, AT URIs, and account identity.
DNSResolverClient Dependency-controlled DNS TXT lookup support used by handle resolution.
XRPCClient Typed XRPC request modeling, routing, query encoding, and the dependency client that runs requests.
ATProtoOAuth AT Protocol OAuth primitives, app metadata, PAR/PKCE/DPoP, sessions, refresh, permission checks, and authorization helpers.
ATProtoServer A small server client surface; currently includes com.atproto.server.getSession.
Lexicon Helpers for open tagged unions and open scalar values when decoding lexicon-shaped data.
LexiconSQLiteData SQLiteData support for open lexicon values.
TCATProtoAuthentication TCA 2 and SwiftUI helpers for authenticated app shells, enabled by the TCA26 trait.
ComposableAtmosphereAuthentication TCA 1.0 OAuth integration support, enabled by the TCA1OAuth trait.

OAuth#

Configure your app's OAuth identity and manifest up front:

import ATProtoOAuth
import Dependencies
import Foundation

prepareDependencies {
  $0.oauthAppConfiguration = OAuth.AppConfiguration(
    appID: OAuth.App.ID(
      URL(string: "https://example.com/oauth-client-metadata.json")!
    ),
    redirectURI: OAuth.App.RedirectURI(
      URL(string: "example:/oauth/callback")!
    )
  )

  $0.oauthAppManifest = OAuth.AppManifest(
    appID: OAuth.App.ID(
      URL(string: "https://example.com/oauth-client-metadata.json")!
    ),
    appName: "Example",
    redirectURIs: [
      OAuth.App.RedirectURI(URL(string: "example:/oauth/callback")!)
    ],
    basePermissions: [.atproto],
    featurePermissions: [
      "posting": [
        .repo("app.bsky.feed.post", actions: [.create])
      ]
    ]
  )
}

OAuth.AppManifest can encode the client metadata you serve from your app ID URL:

@Dependency(\.oauthAppManifest) var oauthAppManifest
let metadata = try oauthAppManifest.metadataData()

Authorization requirements are checked against the current session and the manifest:

try await withDefaultSessionAuthorization(
  scopes: [
    .repo("app.bsky.feed.post", actions: [.create])
  ]
) {
  // Runs with the current DPoP-bound OAuth session installed on outgoing requests.
}

If the session is missing, belongs to the wrong account, lacks a permission that the manifest can request, or asks for a permission the manifest cannot request, Composable Atmosphere throws an OAuth.AuthorizationRequired value that the app can route into the right sign-in or upgrade flow.

Lexicons#

AT Protocol lexicons often contain open unions: the app knows about some $type cases, but must still preserve data it does not understand. The Lexicon product provides Open<Known> and OpenUnion tools for this shape.

Known cases decode into Swift enum cases. Unknown cases stay available as structured partial JSON values so clients can round-trip or inspect them without dropping data.

Testing#

Composable Atmosphere follows the same dependency-control style as the Point-Free libraries it builds on. Instead of reaching directly into globals or live networking, features can override clients for one test:

try await withDependencies {
  $0.pdsHost = "public.api.bsky.app"
  $0.xrpcAuthorizationGate = XRPC.AuthorizationGate { policy, routing, operation in
    // Assert on the policy or install headers before running the transport.
    try await routing.withUnauthenticatedRoute {
      try await operation()
    }
  }
} operation: {
  @Dependency(XRPC.Client.self) var xrpcClient
  _ = try await xrpcClient.run(GetPosts(parameters: .init(uris: [])))
}

The package uses Swift Testing and DependenciesTestSupport.

Installation#

swift-composable-atmosphere is a Swift package targeting iOS 17 and macOS 14 with Swift 6.3.

Add the package to Package.swift:

dependencies: [
  .package(
    name: "swift-composable-atmosphere",
    url: "https://source.woody.fm/swift-composable-atmosphere",
    branch: "main"
  )
]

If you need TCA integration, opt into the relevant package trait:

dependencies: [
  .package(
    name: "swift-composable-atmosphere",
    url: "https://source.woody.fm/swift-composable-atmosphere",
    branch: "main",
    traits: ["TCA26"]  // or ["TCA1OAuth"]
  )
]

Then add the products you need to your target:

.target(
  name: "MyApp",
  dependencies: [
    .product(name: "ATProto", package: "swift-composable-atmosphere"),
    .product(name: "XRPCClient", package: "swift-composable-atmosphere"),
    .product(name: "ATProtoOAuth", package: "swift-composable-atmosphere"),
    .product(name: "TCATProtoAuthentication", package: "swift-composable-atmosphere"),
  ]
)

For local development in the Composable Atmosphere workspace, a path dependency is often more convenient:

.package(name: "swift-composable-atmosphere", path: "../swift-composable-atmosphere")

License#

This repository does not currently include a license file.