a native ios client for gitbay

client ios swift

https://gitbay.org

Commit 74d095160f

74d095160fa4fd3b3f9aa31606db4f5c6eb18be3

parent: d48039b69f

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T05:40:45Z

auth: a 401 anywhere signs the account out cleanly

SessionStore.handle existed but nothing called it, so a revoked token
showed error banners on every screen without ever signing out. The
client now carries an onUnauthorized hook fired on any 401; the session
attaches it when an account is activated, so revocation from any screen
becomes the clean sign-out with the explanation on the sign-in screen.
The sign-in probe client has no hook — a mistyped token stays a local
error.

Verified live: launched with a genuinely revoked token, first dashboard
request met the 401, landed signed out with the message.

Ref #11
gitbay/Auth/SessionStore.swift +16 −1
@@ -75,6 +75,7 @@ final class SessionStore {
7575 client = candidate
7676 currentAccountID = account.id
7777 signedOutMessage = nil
78 watchForRevocation(candidate, account: account)
7879 }
7980
8081 /// Switch to another stored account. Returns false if its token is
@@ -91,8 +92,22 @@ final class SessionStore {
9192
9293 private func activate(_ account: Account, token: String) {
9394 current = account
94 client = makeClient(account.instance, token)
95 let client = makeClient(account.instance, token)
96 self.client = client
9597 currentAccountID = account.id
98 watchForRevocation(client, account: account)
99 }
100
101 /// Any 401 from this account's client some screen, any time
102 /// becomes a clean sign-out. Attached only after a token has worked
103 /// once, so sign-in's own failures stay on the sign-in screen.
104 private func watchForRevocation(_ client: GitbayClient, account: Account) {
105 client.onUnauthorized { [weak self] in
106 Task { @MainActor [weak self] in
107 guard let self, self.accounts.contains(account) else { return }
108 self.expire(account)
109 }
110 }
96111 }
97112
98113 /// Forget an account: token out of the Keychain, account out of the
gitbay/Networking/GitbayClient.swift +11 −1
@@ -26,6 +26,14 @@ nonisolated final class GitbayClient: Sendable {
2626 private let redirectGuard: RedirectGuard
2727 /// Cache keys are scoped by account without holding the token.
2828 private let accountKey: String
29 /// Fired on any 401 the token is expired or revoked. The session
30 /// layer attaches this after sign-in succeeds, so the sign-in probe's
31 /// own 401 (a mistyped token) never signs anyone out.
32 private let unauthorizedHandler = OSAllocatedUnfairLock<(@Sendable () -> Void)?>(initialState: nil)
33
34 func onUnauthorized(_ handler: @escaping @Sendable () -> Void) {
35 unauthorizedHandler.withLock { $0 = handler }
36 }
2937
3038 init(
3139 instance: GitbayInstance,
@@ -270,7 +278,9 @@ nonisolated final class GitbayClient: Sendable {
270278 guard let code = envelope.exitCode.flatMap(ExitCode.init(rawValue:)) else {
271279 switch status {
272280 case 200: return nil
273 case 401: return .unauthorized(message)
281 case 401:
282 unauthorizedHandler.withLock { $0 }?()
283 return .unauthorized(message)
274284 case 403: return .denied(message)
275285 case 404: return .notFound(message)
276286 case 429: return .rateLimited(retryAfter: 0)
gitbayTests/SessionStoreTests.swift +35
@@ -150,6 +150,41 @@ struct SessionStoreTests {
150150 #expect(second.client != nil)
151151 }
152152
153 @Test func aRevokedTokenOnAnyLaterRequestSignsOutCleanly() async throws {
154 let (session, store, stub) = makeSession()
155 stub.enqueue(.init(status: 200, json: whoamiCMC))
156 try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret")
157 let account = try #require(session.current)
158 let client = try #require(session.client)
159 stub.enqueue(.init(status: 401, json: badToken))
160
161 // Some screen's read, long after sign-in, meets the revocation.
162 await #expect(throws: GitbayError.self) {
163 _ = try await client.readList(["repo", "list"], of: RepoSummary.self)
164 }
165 // The sign-out hops through the main actor; give it a beat.
166 try await Task.sleep(for: .milliseconds(200))
167
168 #expect(session.current == nil)
169 #expect(session.client == nil)
170 #expect(store.tokenCount == 0)
171 let message = try #require(session.signedOutMessage)
172 #expect(message.contains(account.username))
173 }
174
175 @Test func aRejectedSignInDoesNotTripTheRevocationWatcher() async throws {
176 let (session, _, stub) = makeSession()
177 stub.enqueue(.init(status: 401, json: badToken))
178
179 await #expect(throws: GitbayError.self) {
180 try await session.signIn(instanceURL: "gitbay.org", token: "gb_wrong")
181 }
182 try await Task.sleep(for: .milliseconds(200))
183
184 // A bad pasted token is a sign-in error, not a revocation notice.
185 #expect(session.signedOutMessage == nil)
186 }
187
153188 @Test func aRevokedTokenBecomesACleanSignOutWithAMessage() async throws {
154189 let (session, store, stub) = makeSession()
155190 stub.enqueue(.init(status: 200, json: whoamiCMC))