krz/octosentry

macOS menu bar app to monitor GitHub security alerts

clone: git clone https://gitbay.org/krz/octosentry.git

89d41cc954f40c38f68fe2fe43913069057d38ac

signed_unknown_key

author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T21:33:04Z
committer: <noreply@github.com>

Support GitHub Enterprise Server (#34)

The API base URL is configurable per account, defaulting to github.com.
Enterprise Server differs structurally, not just by hostname: REST lives
at https://HOST/api/v3 on the same host, and device flow needs an OAuth
app registered on the instance, so the client ID travels with the host.

Host is a property of an account rather than a global setting, so
github.com and an employer's instance can be watched at once. Keychain
items for Enterprise accounts are namespaced by host, since user ids
are only unique within an instance.

Account gets an explicit decoder: synthesized decoding ignores property
defaults, so an account written before this change would fail to decode
and take the whole state file with it.

Deep-links needed no change — rows already open each alert's html_url
from the API response, which Enterprise returns pointing at its own
host.

Closes #20
 octosentry/Account.swift                 |  45 +++++++++--
 octosentry/AuthStore.swift               |  43 ++++++-----
 octosentry/GitHubDeviceAuthClient.swift  |  15 ++--
 octosentry/GitHubHost.swift              |  67 +++++++++++++++++
 octosentry/GitHubSecurityAPIClient.swift |  11 +--
 octosentry/SecurityEventListView.swift   |  50 ++++++++++++-
 octosentry/SecurityEventStore.swift      |   4 +-
 octosentryTests/GitHubHostTests.swift    | 124 +++++++++++++++++++++++++++++++
 8 files changed, 322 insertions(+), 37 deletions(-)

diff --git a/octosentry/Account.swift b/octosentry/Account.swift
index a63df1f..21ca5dc 100644
--- a/octosentry/Account.swift
+++ b/octosentry/Account.swift
@@ -20,9 +20,37 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable {
     var login: String
     var keychainAccount: String
     var hasRepoScope: Bool
+    /// Which GitHub this account lives on. Absent in files written before
+    /// Enterprise support, which means github.com.
+    var host: GitHubHost = .dotCom
+
+    enum CodingKeys: String, CodingKey {
+        case id, login, keychainAccount, hasRepoScope, host
+    }
+
+    init(id: Int, login: String, keychainAccount: String, hasRepoScope: Bool, host: GitHubHost = .dotCom) {
+        self.id = id
+        self.login = login
+        self.keychainAccount = keychainAccount
+        self.hasRepoScope = hasRepoScope
+        self.host = host
+    }
+
+    // Synthesized decoding ignores property defaults, so an account written
+    // before Enterprise support would fail to decode and take the whole
+    // state file down with it.
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        id = try container.decode(Int.self, forKey: .id)
+        login = try container.decode(String.self, forKey: .login)
+        keychainAccount = try container.decode(String.self, forKey: .keychainAccount)
+        hasRepoScope = try container.decode(Bool.self, forKey: .hasRepoScope)
+        host = try container.decodeIfPresent(GitHubHost.self, forKey: .host) ?? .dotCom
+    }
 
     var displayName: String {
-        login.isEmpty ? "GitHub account" : login
+        let name = login.isEmpty ? "GitHub account" : login
+        return host.isDotCom ? name : "\(name) @ \(host.displayName)"
     }
 
     /// The account an upgrading install already has a token for.
@@ -31,16 +59,21 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable {
             id: 0,
             login: "",
             keychainAccount: KeychainTokenStore.legacyAccount,
-            hasRepoScope: false
+            hasRepoScope: false,
+            host: .dotCom
         )
     }
 
-    static func new(id: Int, login: String, hasRepoScope: Bool) -> Account {
-        Account(
+    static func new(id: Int, login: String, hasRepoScope: Bool, host: GitHubHost = .dotCom) -> Account {
+        // Ids are only unique within an instance, so a GHES account's
+        // Keychain item is namespaced by host too.
+        let suffix = host.isDotCom ? "\(id)" : "\(host.displayName)-\(id)"
+        return Account(
             id: id,
             login: login,
-            keychainAccount: "account-\(id)",
-            hasRepoScope: hasRepoScope
+            keychainAccount: "account-\(suffix)",
+            hasRepoScope: hasRepoScope,
+            host: host
         )
     }
 }
diff --git a/octosentry/AuthStore.swift b/octosentry/AuthStore.swift
index becaf04..b39df40 100644
--- a/octosentry/AuthStore.swift
+++ b/octosentry/AuthStore.swift
@@ -21,7 +21,6 @@ final class AuthStore {
     private(set) var errorMessage: String?
     private(set) var accounts: [Account] = []
 
-    private let client = GitHubDeviceAuthClient()
     private let persistenceStore = PersistenceStore()
     private var authorizationTask: Task<Void, Never>?
 
@@ -43,21 +42,22 @@ final class AuthStore {
         accounts.contains(where: \.hasRepoScope)
     }
 
-    func signIn() {
-        beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope)
+    func signIn(host: GitHubHost = .dotCom) {
+        beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host)
     }
 
-    /// Adds another identity. Same flow as signing in — GitHub decides which
-    /// account authorizes the code.
-    func addAccount() {
-        beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope)
+    /// Adds another identity, optionally on a GitHub Enterprise Server
+    /// instance. Same flow either way — GitHub decides which account
+    /// authorizes the code.
+    func addAccount(host: GitHubHost = .dotCom) {
+        beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host)
     }
 
     /// Re-runs device auth with broader scope so the repo picker can list
     /// repos. Only called explicitly from the repo picker UI, never on
     /// the default sign-in path.
-    func requestRepoAccess() {
-        beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope)
+    func requestRepoAccess(host: GitHubHost = .dotCom) {
+        beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope, host: host)
     }
 
     /// Signs out one account, leaving the others alone.
@@ -121,7 +121,7 @@ final class AuthStore {
 
         for account in unresolved {
             guard let token = KeychainTokenStore.load(account: account.keychainAccount),
-                  let user = try? await GitHubSecurityAPIClient(token: token).fetchCurrentUser(),
+                  let user = try? await GitHubSecurityAPIClient(token: token, host: account.host).fetchCurrentUser(),
                   let index = persisted.accounts.firstIndex(where: { $0.keychainAccount == account.keychainAccount })
             else { continue }
 
@@ -143,13 +143,14 @@ final class AuthStore {
         }
     }
 
-    private func beginAuthorization(scope: String) {
+    private func beginAuthorization(scope: String, host: GitHubHost) {
         guard authorizationTask == nil else { return }
         errorMessage = nil
 
         authorizationTask = Task {
             defer { authorizationTask = nil }
             do {
+                let client = GitHubDeviceAuthClient(host: host)
                 let deviceCode = try await client.requestDeviceCode(scope: scope)
                 state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri)
 
@@ -158,7 +159,7 @@ final class AuthStore {
                     interval: deviceCode.interval,
                     expiresIn: deviceCode.expiresIn
                 )
-                try await register(token: token, grantedRepoScope: scope.contains("repo"))
+                try await register(token: token, grantedRepoScope: scope.contains("repo"), host: host)
                 state = .signedIn
             } catch {
                 errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
@@ -173,30 +174,36 @@ final class AuthStore {
     /// Stores a freshly authorized token under its own Keychain item and
     /// records the account. Re-authorizing an account already present updates
     /// it in place rather than adding a duplicate.
-    private func register(token: String, grantedRepoScope: Bool) async throws {
-        let user = try await GitHubSecurityAPIClient(token: token).fetchCurrentUser()
+    private func register(token: String, grantedRepoScope: Bool, host: GitHubHost) async throws {
+        let user = try await GitHubSecurityAPIClient(token: token, host: host).fetchCurrentUser()
 
         var persisted = await persistenceStore.load()
 
-        if let index = persisted.accounts.firstIndex(where: { $0.id == user.id }) {
+        if let index = persisted.accounts.firstIndex(where: { $0.id == user.id && $0.host == host }) {
             persisted.accounts[index].login = user.login
             persisted.accounts[index].hasRepoScope = grantedRepoScope
             try KeychainTokenStore.save(token, account: persisted.accounts[index].keychainAccount)
-        } else if let index = persisted.accounts.firstIndex(where: { $0.id == 0 }) {
+        } else if host.isDotCom, let index = persisted.accounts.firstIndex(where: { $0.id == 0 }) {
             // The adopted single-account entry, now identified.
             let keychainAccount = persisted.accounts[index].keychainAccount
             persisted.accounts[index] = Account(
                 id: user.id,
                 login: user.login,
                 keychainAccount: keychainAccount,
-                hasRepoScope: grantedRepoScope
+                hasRepoScope: grantedRepoScope,
+                host: host
             )
             for repoIndex in persisted.watchedRepos.indices where persisted.watchedRepos[repoIndex].accountID == 0 {
                 persisted.watchedRepos[repoIndex].accountID = user.id
             }
             try KeychainTokenStore.save(token, account: keychainAccount)
         } else {
-            let account = Account.new(id: user.id, login: user.login, hasRepoScope: grantedRepoScope)
+            let account = Account.new(
+                id: user.id,
+                login: user.login,
+                hasRepoScope: grantedRepoScope,
+                host: host
+            )
             try KeychainTokenStore.save(token, account: account.keychainAccount)
             persisted.accounts.append(account)
         }
diff --git a/octosentry/GitHubDeviceAuthClient.swift b/octosentry/GitHubDeviceAuthClient.swift
index 3105733..a6020b5 100644
--- a/octosentry/GitHubDeviceAuthClient.swift
+++ b/octosentry/GitHubDeviceAuthClient.swift
@@ -12,8 +12,12 @@ import Foundation
 
 actor GitHubDeviceAuthClient {
     // Public client identifier for the "octosentry" OAuth App (Device Flow enabled).
-    // Not a secret — safe to embed in source.
-    private let clientID = "Ov23li6tqaTghDc4IJYv"
+    // Not a secret — safe to embed in source. A GitHub Enterprise Server
+    // instance needs its own admin-registered app instead (#20).
+    static let dotComClientID = "Ov23li6tqaTghDc4IJYv"
+
+    private let host: GitHubHost
+    private var clientID: String { host.clientID ?? Self.dotComClientID }
 
     // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert
     // access. Classic OAuth scopes have no read-only variant (unlike fine-grained
@@ -26,13 +30,14 @@ actor GitHubDeviceAuthClient {
 
     private let session: URLSession
 
-    init(session: URLSession = .shared) {
+    init(host: GitHubHost = .dotCom, session: URLSession = .shared) {
+        self.host = host
         self.session = session
     }
 
     func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse {
         let data = try await post(
-            url: URL(string: "https://github.com/login/device/code")!,
+            url: host.deviceCodeURL,
             parameters: ["client_id": clientID, "scope": scope]
         )
         do {
@@ -52,7 +57,7 @@ actor GitHubDeviceAuthClient {
             try Task.checkCancellation()
 
             let data = try await post(
-                url: URL(string: "https://github.com/login/oauth/access_token")!,
+                url: host.accessTokenURL,
                 parameters: [
                     "client_id": clientID,
                     "device_code": deviceCode,
diff --git a/octosentry/GitHubHost.swift b/octosentry/GitHubHost.swift
new file mode 100644
index 0000000..db9bfe1
--- /dev/null
+++ b/octosentry/GitHubHost.swift
@@ -0,0 +1,67 @@
+//
+//  GitHubHost.swift
+//  octosentry
+//
+//  Which GitHub a token talks to. github.com and GitHub Enterprise Server
+//  differ in more than a hostname: GHES puts the REST API under /api/v3 on
+//  the same host rather than on a separate api. domain, and its device flow
+//  needs an OAuth app registered by an instance admin, so octosentry's own
+//  client ID doesn't apply.
+//
+//  Host is a property of an account (#19), not a global setting — someone
+//  can reasonably watch repos on github.com and on their employer's GHES at
+//  the same time.
+//
+
+import Foundation
+
+nonisolated struct GitHubHost: Codable, Equatable, Hashable {
+    /// nil means github.com. Otherwise the GHES web host, without a scheme.
+    var host: String?
+    /// Required for GHES; github.com uses octosentry's registered app.
+    var clientID: String?
+
+    static let dotCom = GitHubHost(host: nil, clientID: nil)
+
+    /// Accepts what a user is likely to paste — with or without a scheme,
+    /// with or without a trailing slash or path.
+    init(host: String?, clientID: String?) {
+        self.host = host.flatMap(Self.normalize)
+        let trimmedClientID = clientID?.trimmingCharacters(in: .whitespacesAndNewlines)
+        self.clientID = (trimmedClientID?.isEmpty == false) ? trimmedClientID : nil
+    }
+
+    private static func normalize(_ raw: String) -> String? {
+        var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        for prefix in ["https://", "http://"] where value.hasPrefix(prefix) {
+            value.removeFirst(prefix.count)
+        }
+        if let slash = value.firstIndex(of: "/") {
+            value = String(value[value.startIndex..<slash])
+        }
+        guard !value.isEmpty, value != "github.com" else { return nil }
+        return value
+    }
+
+    var isDotCom: Bool { host == nil }
+
+    var displayName: String { host ?? "github.com" }
+
+    /// GHES serves the REST API from the same host under /api/v3.
+    var apiBaseURL: URL {
+        guard let host else { return URL(string: "https://api.github.com")! }
+        return URL(string: "https://\(host)/api/v3")!
+    }
+
+    var webBaseURL: URL {
+        URL(string: "https://\(host ?? "github.com")")!
+    }
+
+    var deviceCodeURL: URL {
+        webBaseURL.appendingPathComponent("login/device/code")
+    }
+
+    var accessTokenURL: URL {
+        webBaseURL.appendingPathComponent("login/oauth/access_token")
+    }
+}
diff --git a/octosentry/GitHubSecurityAPIClient.swift b/octosentry/GitHubSecurityAPIClient.swift
index 8c0171e..d6a7c1a 100644
--- a/octosentry/GitHubSecurityAPIClient.swift
+++ b/octosentry/GitHubSecurityAPIClient.swift
@@ -13,7 +13,7 @@ import Foundation
 actor GitHubSecurityAPIClient {
     private let token: String
     private let session: URLSession
-    private let baseURL = URL(string: "https://api.github.com")!
+    private let baseURL: URL
 
     private static let decoder: JSONDecoder = {
         let decoder = JSONDecoder()
@@ -21,8 +21,9 @@ actor GitHubSecurityAPIClient {
         return decoder
     }()
 
-    init(token: String, session: URLSession = .shared) {
+    init(token: String, host: GitHubHost = .dotCom, session: URLSession = .shared) {
         self.token = token
+        self.baseURL = host.apiBaseURL
         self.session = session
     }
 
@@ -94,7 +95,7 @@ actor GitHubSecurityAPIClient {
     /// sign-in scope). Used by the repo picker (#15).
     func fetchAccessibleRepos() async throws -> [String] {
         var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
-        components.path = "/user/repos"
+        components.path = baseURL.path + "/user/repos"
         components.queryItems = [
             URLQueryItem(name: "per_page", value: "100"),
             URLQueryItem(name: "sort", value: "full_name"),
@@ -107,14 +108,14 @@ actor GitHubSecurityAPIClient {
     /// alerts attributed. Needs no scope beyond a valid user token.
     func fetchCurrentUser() async throws -> GitHubUserDTO {
         var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
-        components.path = "/user"
+        components.path = baseURL.path + "/user"
         let (data, _) = try await fetchData(url: components.url!)
         return try decode(data)
     }
 
     private func alertsURL(owner: String, repo: String, path: String) -> URL {
         var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
-        components.path = "/repos/\(owner)/\(repo)/\(path)"
+        components.path = baseURL.path + "/repos/\(owner)/\(repo)/\(path)"
         components.queryItems = [
             URLQueryItem(name: "state", value: "open"),
             URLQueryItem(name: "per_page", value: "100"),
diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift
index c5761c8..3ace252 100644
--- a/octosentry/SecurityEventListView.swift
+++ b/octosentry/SecurityEventListView.swift
@@ -347,6 +347,8 @@ private struct RepoManagerView: View {
                 .buttonStyle(.plain)
                 .foregroundStyle(Color.accentColor)
 
+                EnterpriseSignInView(authStore: authStore)
+
                 if let errorMessage = store.watchListErrorMessage {
                     Text(errorMessage)
                         .font(.caption2)
@@ -485,7 +487,7 @@ private struct RepoManagerView: View {
 
     private func startBrowsing(_ account: Account) {
         guard account.hasRepoScope else {
-            authStore.requestRepoAccess()
+            authStore.requestRepoAccess(host: account.host)
             return
         }
         browsingAccount = account
@@ -509,6 +511,52 @@ private struct RepoManagerView: View {
     }
 }
 
+/// Signing in to a GitHub Enterprise Server instance. Collapsed by default —
+/// most people are on github.com, and GHES needs details they have to get
+/// from an instance admin.
+private struct EnterpriseSignInView: View {
+    var authStore: AuthStore
+    @State private var isExpanded = false
+    @State private var hostText = ""
+    @State private var clientIDText = ""
+
+    private var host: GitHubHost {
+        GitHubHost(host: hostText, clientID: clientIDText)
+    }
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 6) {
+            Button {
+                isExpanded.toggle()
+            } label: {
+                Label("Add a GitHub Enterprise account", systemImage: "building.2")
+                    .font(.caption)
+            }
+            .buttonStyle(.plain)
+            .foregroundStyle(Color.accentColor)
+
+            if isExpanded {
+                TextField("github.example.com", text: $hostText)
+                    .textFieldStyle(.roundedBorder)
+                TextField("OAuth app client ID", text: $clientIDText)
+                    .textFieldStyle(.roundedBorder)
+
+                Text("Device flow on Enterprise Server needs an OAuth app registered on the instance. Ask an admin for its client ID.")
+                    .font(.caption2)
+                    .foregroundStyle(.secondary)
+
+                Button("Sign In") {
+                    authStore.addAccount(host: host)
+                    isExpanded = false
+                    hostText = ""
+                    clientIDText = ""
+                }
+                .disabled(host.isDotCom || host.clientID == nil)
+            }
+        }
+    }
+}
+
 private struct FilterLabel: View {
     let title: String
     let count: Int
diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift
index 1e476bf..f27f621 100644
--- a/octosentry/SecurityEventStore.swift
+++ b/octosentry/SecurityEventStore.swift
@@ -111,7 +111,7 @@ final class SecurityEventStore {
                 errors.append("\(repoFullName): no signed-in account can reach this repo.")
                 continue
             }
-            let client = GitHubSecurityAPIClient(token: token)
+            let client = GitHubSecurityAPIClient(token: token, host: account.host)
             let label = accountsByID.count > 1
                 ? "\(repoFullName) (\(account.displayName))"
                 : repoFullName
@@ -257,7 +257,7 @@ final class SecurityEventStore {
         guard let token = KeychainTokenStore.load(account: account.keychainAccount) else {
             throw GitHubAPIError.missingToken
         }
-        return try await GitHubSecurityAPIClient(token: token).fetchAccessibleRepos()
+        return try await GitHubSecurityAPIClient(token: token, host: account.host).fetchAccessibleRepos()
     }
 
     /// Local-only triage state (spec §11) — no API write, no scope beyond
diff --git a/octosentryTests/GitHubHostTests.swift b/octosentryTests/GitHubHostTests.swift
new file mode 100644
index 0000000..98bf981
--- /dev/null
+++ b/octosentryTests/GitHubHostTests.swift
@@ -0,0 +1,124 @@
+//
+//  GitHubHostTests.swift
+//  octosentryTests
+//
+
+import Foundation
+import Testing
+@testable import octosentry
+
+struct GitHubHostTests {
+
+    // MARK: - github.com
+
+    @Test func theDefaultIsGitHubDotCom() {
+        #expect(GitHubHost.dotCom.isDotCom)
+        #expect(GitHubHost.dotCom.displayName == "github.com")
+        #expect(GitHubHost.dotCom.apiBaseURL.absoluteString == "https://api.github.com")
+        #expect(GitHubHost.dotCom.webBaseURL.absoluteString == "https://github.com")
+    }
+
+    @Test func dotComKeepsItsExistingAuthEndpoints() {
+        #expect(GitHubHost.dotCom.deviceCodeURL.absoluteString == "https://github.com/login/device/code")
+        #expect(GitHubHost.dotCom.accessTokenURL.absoluteString == "https://github.com/login/oauth/access_token")
+    }
+
+    // Naming github.com explicitly is still github.com, not an enterprise host.
+    @Test func namingGitHubDotComExplicitlyIsNotEnterprise() {
+        #expect(GitHubHost(host: "github.com", clientID: "abc").isDotCom)
+        #expect(GitHubHost(host: "https://github.com", clientID: "abc").isDotCom)
+    }
+
+    // MARK: - Enterprise Server
+
+    // GHES serves the REST API from the same host under /api/v3, not from a
+    // separate api. domain.
+    @Test func enterpriseAPILivesUnderApiV3OnTheSameHost() {
+        let host = GitHubHost(host: "github.example.com", clientID: "abc")
+
+        #expect(host.apiBaseURL.absoluteString == "https://github.example.com/api/v3")
+        #expect(host.webBaseURL.absoluteString == "https://github.example.com")
+        #expect(!host.isDotCom)
+    }
+
+    @Test func enterpriseAuthEndpointsAreOnTheInstance() {
+        let host = GitHubHost(host: "github.example.com", clientID: "abc")
+
+        #expect(host.deviceCodeURL.absoluteString == "https://github.example.com/login/device/code")
+        #expect(host.accessTokenURL.absoluteString == "https://github.example.com/login/oauth/access_token")
+    }
+
+    // MARK: - Normalizing what a user pastes
+
+    @Test func aPastedURLIsReducedToItsHost() {
+        for input in [
+            "https://github.example.com",
+            "http://github.example.com",
+            "github.example.com/",
+            "https://github.example.com/some/path",
+            "  GitHub.Example.com  ",
+        ] {
+            #expect(GitHubHost(host: input, clientID: "abc").host == "github.example.com", "input: \(input)")
+        }
+    }
+
+    @Test func anEmptyHostMeansGitHubDotCom() {
+        #expect(GitHubHost(host: "", clientID: nil).isDotCom)
+        #expect(GitHubHost(host: "   ", clientID: nil).isDotCom)
+        #expect(GitHubHost(host: nil, clientID: nil).isDotCom)
+    }
+
+    @Test func anEmptyClientIDIsTreatedAsAbsent() {
+        #expect(GitHubHost(host: "github.example.com", clientID: "").clientID == nil)
+        #expect(GitHubHost(host: "github.example.com", clientID: "  ").clientID == nil)
+        #expect(GitHubHost(host: "github.example.com", clientID: " abc ").clientID == "abc")
+    }
+
+    // MARK: - Persistence
+
+    @Test func roundTripsThroughCodable() throws {
+        for host in [GitHubHost.dotCom, GitHubHost(host: "github.example.com", clientID: "abc")] {
+            let decoded = try JSONDecoder().decode(GitHubHost.self, from: try JSONEncoder().encode(host))
+            #expect(decoded == host)
+        }
+    }
+
+    // An account written before Enterprise support has no host field.
+    @Test func anAccountWithoutAHostFieldDecodesAsGitHubDotCom() throws {
+        let json = """
+        {"id": 7, "login": "octocat", "keychainAccount": "account-7", "hasRepoScope": false}
+        """
+
+        let account = try JSONDecoder().decode(Account.self, from: Data(json.utf8))
+
+        #expect(account.host == .dotCom)
+        #expect(account.displayName == "octocat")
+    }
+
+    // MARK: - Accounts
+
+    // Ids are only unique within an instance, so two accounts that happen to
+    // share an id on different hosts must not share a Keychain item.
+    @Test func sameIdOnDifferentHostsGetsDifferentKeychainItems() {
+        let dotCom = Account.new(id: 7, login: "octocat", hasRepoScope: false)
+        let enterprise = Account.new(
+            id: 7,
+            login: "octocat",
+            hasRepoScope: false,
+            host: GitHubHost(host: "github.example.com", clientID: "abc")
+        )
+
+        #expect(dotCom.keychainAccount != enterprise.keychainAccount)
+    }
+
+    @Test func enterpriseAccountsAreLabelledWithTheirHost() {
+        let account = Account.new(
+            id: 7,
+            login: "octocat",
+            hasRepoScope: false,
+            host: GitHubHost(host: "github.example.com", clientID: "abc")
+        )
+
+        #expect(account.displayName == "octocat @ github.example.com")
+    }
+}