Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 1 | import { encodeOAuthClient } from "@g1t/contracts/oauth"; |
| The phone app signs in from Expo Go and stays signed in there. Go sends sign-in back to its own exp:// address, which follows the computer's network address, and a client id encodes its redirect, so a refresh after the address changed named another client and was refused. Credentials now keep the client id they were issued to and refresh with it; Go's client is named "g1t in Expo Go" on the approval page and in Connected applications. The README leads with running in Expo Go, and says what a development build adds and how to point the phone at a g1t on your computer. | 2 | import Constants, { ExecutionEnvironment } from "expo-constants"; |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 3 | import * as AuthSession from "expo-auth-session"; |
| 4 | import * as WebBrowser from "expo-web-browser"; | |
| 5 | import { Platform } from "react-native"; | |
| 6 | ||
| 7 | import type { Credentials } from "./credentials"; | |
| 8 | import { OAUTH_CALLBACK_PATH } from "./links"; | |
| 9 | import type { Server } from "./server"; | |
| 10 | ||
| 11 | /** | |
| 12 | * Signing in with g1t's OAuth 2.1, as any public client does | |
| 13 | * (docs.g1t.sh/guides/authentication/#signing-in-with-oauth): the | |
| 14 | * authorization code flow with PKCE (S256), no client secret, and a client | |
| 15 | * id that encodes the app's name and where it is sent back, so nothing is | |
| 16 | * registered first. | |
| 17 | * | |
| 18 | * The person approves on the site, in the system's browser, which keeps | |
| 19 | * their g1t session (and passkeys, password managers, two-factor) out of | |
| 20 | * the app. The site sends them back to `g1t://oauth/callback`. | |
| 21 | * | |
| 22 | * The app appears in Settings → Connected applications as "g1t for | |
| 23 | * Android" or "g1t for iOS", where its access can be narrowed or ended. | |
| 24 | */ | |
| 25 | ||
| 26 | // Lets the browser hand its result back when the app was in the background. | |
| 27 | WebBrowser.maybeCompleteAuthSession(); | |
| 28 | ||
| 29 | /** | |
| 30 | * What the app asks for: what Chat, Code and Customize show and do. The | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 31 | * person can untick any of these on the approval page. `chat:*` opens |
| 32 | * Chat's routes and live sockets on the site, and `notifications:*` the | |
| 33 | * phone's registration for pushes (`/-/notify`), and nothing else there. | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 34 | */ |
| 35 | export const SCOPES = [ | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 36 | "chat:read", |
| 37 | "chat:write", | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 38 | "account:read", |
| 39 | "workspace:read", | |
| 40 | "repo:read", | |
| 41 | "code:read", | |
| 42 | "issues:read", | |
| 43 | "issues:write", | |
| 44 | "pull_requests:read", | |
| 45 | "pull_requests:write", | |
| 46 | "checks:read", | |
| 47 | "workflows:read", | |
| 48 | "notifications:read", | |
| 49 | "notifications:write", | |
| 50 | "agents:run", | |
| 51 | "memory:read", | |
| 52 | "artifacts:read", | |
| 53 | ]; | |
| 54 | ||
| The phone app signs in from Expo Go and stays signed in there. Go sends sign-in back to its own exp:// address, which follows the computer's network address, and a client id encodes its redirect, so a refresh after the address changed named another client and was refused. Credentials now keep the client id they were issued to and refresh with it; Go's client is named "g1t in Expo Go" on the approval page and in Connected applications. The README leads with running in Expo Go, and says what a development build adds and how to point the phone at a g1t on your computer. | 55 | /** Whether the app is running inside Expo Go, while developing. */ |
| 56 | export const IN_EXPO_GO = Constants.executionEnvironment === ExecutionEnvironment.StoreClient; | |
| 57 | ||
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 58 | /** The name shown on the approval page and in Connected applications. */ |
| The phone app signs in from Expo Go and stays signed in there. Go sends sign-in back to its own exp:// address, which follows the computer's network address, and a client id encodes its redirect, so a refresh after the address changed named another client and was refused. Credentials now keep the client id they were issued to and refresh with it; Go's client is named "g1t in Expo Go" on the approval page and in Connected applications. The README leads with running in Expo Go, and says what a development build adds and how to point the phone at a g1t on your computer. | 59 | const CLIENT_NAME = IN_EXPO_GO ? "g1t in Expo Go" : Platform.OS === "ios" ? "g1t for iOS" : "g1t for Android"; |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 60 | |
| 61 | /** | |
| 62 | * Where the site sends the person back: `g1t://oauth/callback` in a build | |
| The phone app signs in from Expo Go and stays signed in there. Go sends sign-in back to its own exp:// address, which follows the computer's network address, and a client id encodes its redirect, so a refresh after the address changed named another client and was refused. Credentials now keep the client id they were issued to and refresh with it; Go's client is named "g1t in Expo Go" on the approval page and in Connected applications. The README leads with running in Expo Go, and says what a development build adds and how to point the phone at a g1t on your computer. | 63 | * of the app, and Expo Go's own address while developing in it |
| 64 | * (`exp://192.168.1.20:8081/--/oauth/callback`), which g1t accepts as an | |
| 65 | * application's own scheme. Since the client id encodes it, Go's id | |
| 66 | * changes with the computer's address; credentials keep the id they were | |
| 67 | * issued to, for refreshing. | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 68 | */ |
| 69 | export function redirectUri(): string { | |
| 70 | return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) }); | |
| 71 | } | |
| 72 | ||
| 73 | export function clientId(redirect: string): string { | |
| 74 | const id = encodeOAuthClient({ name: CLIENT_NAME, redirectUris: [redirect] }); | |
| 75 | if (!id) throw new Error(`g1t does not accept ${redirect} as a place to send you back.`); | |
| 76 | return id; | |
| 77 | } | |
| 78 | ||
| 79 | type Metadata = { | |
| 80 | issuer: string; | |
| 81 | authorization_endpoint: string; | |
| 82 | token_endpoint: string; | |
| 83 | revocation_endpoint?: string; | |
| 84 | }; | |
| 85 | ||
| 86 | /** The server's OAuth metadata (RFC 8414), from its API. */ | |
| 87 | async function metadata(server: Server): Promise<Metadata> { | |
| 88 | const response = await fetch(`${server.api}/.well-known/oauth-authorization-server`, { | |
| 89 | headers: { accept: "application/json" }, | |
| 90 | }); | |
| 91 | if (!response.ok) throw new Error(`${server.api} did not say how to sign in (${response.status}).`); | |
| 92 | return (await response.json()) as Metadata; | |
| 93 | } | |
| 94 | ||
| 95 | function discovery(meta: Metadata): AuthSession.DiscoveryDocument { | |
| 96 | return { | |
| 97 | authorizationEndpoint: meta.authorization_endpoint, | |
| 98 | tokenEndpoint: meta.token_endpoint, | |
| 99 | revocationEndpoint: meta.revocation_endpoint, | |
| 100 | }; | |
| 101 | } | |
| 102 | ||
| 103 | export class SignInCancelled extends Error { | |
| 104 | constructor() { | |
| 105 | super("Sign-in was cancelled."); | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | /** | |
| 110 | * Opens the site's approval page and, once the person approves, trades the | |
| 111 | * code for tokens. Throws {@link SignInCancelled} when they close the page | |
| 112 | * or deny. | |
| 113 | */ | |
| 114 | export async function signInWithOAuth(server: Server): Promise<Credentials> { | |
| 115 | const meta = await metadata(server); | |
| 116 | const redirect = redirectUri(); | |
| 117 | const id = clientId(redirect); | |
| 118 | const request = new AuthSession.AuthRequest({ | |
| 119 | clientId: id, | |
| 120 | redirectUri: redirect, | |
| 121 | scopes: SCOPES, | |
| 122 | usePKCE: true, | |
| 123 | codeChallengeMethod: AuthSession.CodeChallengeMethod.S256, | |
| 124 | }); | |
| 125 | const result = await request.promptAsync(discovery(meta)); | |
| 126 | if (result.type === "cancel" || result.type === "dismiss") throw new SignInCancelled(); | |
| 127 | if (result.type !== "success") { | |
| 128 | const denied = result.type === "error" && result.params.error === "access_denied"; | |
| 129 | if (denied) throw new SignInCancelled(); | |
| 130 | throw new Error(result.type === "error" ? (result.params.error_description ?? result.params.error ?? "Sign-in failed.") : "Sign-in failed."); | |
| 131 | } | |
| 132 | // RFC 9207: the code must come from the server the app asked. | |
| 133 | if (result.params.iss && result.params.iss.replace(/\/$/, "") !== meta.issuer.replace(/\/$/, "")) { | |
| 134 | throw new Error("The sign-in came back from a different server than the one asked. Try again."); | |
| 135 | } | |
| 136 | const tokens = await AuthSession.exchangeCodeAsync( | |
| 137 | { | |
| 138 | clientId: id, | |
| 139 | code: result.params.code, | |
| 140 | redirectUri: redirect, | |
| 141 | extraParams: { code_verifier: request.codeVerifier ?? "" }, | |
| 142 | }, | |
| 143 | discovery(meta), | |
| 144 | ); | |
| The phone app signs in from Expo Go and stays signed in there. Go sends sign-in back to its own exp:// address, which follows the computer's network address, and a client id encodes its redirect, so a refresh after the address changed named another client and was refused. Credentials now keep the client id they were issued to and refresh with it; Go's client is named "g1t in Expo Go" on the approval page and in Connected applications. The README leads with running in Expo Go, and says what a development build adds and how to point the phone at a g1t on your computer. | 145 | return fromTokenResponse(tokens, id); |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 146 | } |
| 147 | ||
| The phone app signs in from Expo Go and stays signed in there. Go sends sign-in back to its own exp:// address, which follows the computer's network address, and a client id encodes its redirect, so a refresh after the address changed named another client and was refused. Credentials now keep the client id they were issued to and refresh with it; Go's client is named "g1t in Expo Go" on the approval page and in Connected applications. The README leads with running in Expo Go, and says what a development build adds and how to point the phone at a g1t on your computer. | 148 | function fromTokenResponse(tokens: AuthSession.TokenResponse, clientId: string): Credentials { |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 149 | if (!tokens.refreshToken) throw new Error("g1t did not return a refresh token."); |
| 150 | return { | |
| 151 | kind: "oauth", | |
| 152 | accessToken: tokens.accessToken, | |
| 153 | refreshToken: tokens.refreshToken, | |
| 154 | expiresAt: (tokens.issuedAt + (tokens.expiresIn ?? 30 * 24 * 3600)) * 1000, | |
| 155 | scope: tokens.scope ?? "", | |
| The phone app signs in from Expo Go and stays signed in there. Go sends sign-in back to its own exp:// address, which follows the computer's network address, and a client id encodes its redirect, so a refresh after the address changed named another client and was refused. Credentials now keep the client id they were issued to and refresh with it; Go's client is named "g1t in Expo Go" on the approval page and in Connected applications. The README leads with running in Expo Go, and says what a development build adds and how to point the phone at a g1t on your computer. | 156 | clientId, |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 157 | }; |
| 158 | } | |
| 159 | ||
| 160 | /** Raised when the refresh token no longer works: the person must sign in again. */ | |
| 161 | export class SignedOut extends Error { | |
| 162 | constructor(message = "You were signed out. Sign in again.") { | |
| 163 | super(message); | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 167 | /** | |
| 168 | * The next pair of tokens. A refresh token works once: the old access | |
| 169 | * token stops working, so callers share one refresh at a time | |
| 170 | * (lib/client.ts). | |
| 171 | */ | |
| 172 | export async function refresh(server: Server, credentials: Extract<Credentials, { kind: "oauth" }>): Promise<Credentials> { | |
| 173 | const meta = await metadata(server); | |
| The phone app signs in from Expo Go and stays signed in there. Go sends sign-in back to its own exp:// address, which follows the computer's network address, and a client id encodes its redirect, so a refresh after the address changed named another client and was refused. Credentials now keep the client id they were issued to and refresh with it; Go's client is named "g1t in Expo Go" on the approval page and in Connected applications. The README leads with running in Expo Go, and says what a development build adds and how to point the phone at a g1t on your computer. | 174 | const id = credentials.clientId ?? clientId(redirectUri()); |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 175 | try { |
| The phone app signs in from Expo Go and stays signed in there. Go sends sign-in back to its own exp:// address, which follows the computer's network address, and a client id encodes its redirect, so a refresh after the address changed named another client and was refused. Credentials now keep the client id they were issued to and refresh with it; Go's client is named "g1t in Expo Go" on the approval page and in Connected applications. The README leads with running in Expo Go, and says what a development build adds and how to point the phone at a g1t on your computer. | 176 | const tokens = await AuthSession.refreshAsync({ clientId: id, refreshToken: credentials.refreshToken }, discovery(meta)); |
| 177 | return fromTokenResponse(tokens, id); | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 178 | } catch (error) { |
| 179 | // invalid_grant: used, expired or revoked in Connected applications. | |
| 180 | if (error instanceof AuthSession.TokenError) throw new SignedOut(); | |
| 181 | throw error; | |
| 182 | } | |
| 183 | } |