Skip to content
183 linesCodeBlameRaw

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.1import { 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.2import 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.3import * as AuthSession from "expo-auth-session";
4import * as WebBrowser from "expo-web-browser";
5import { Platform } from "react-native";
6
7import type { Credentials } from "./credentials";
8import { OAUTH_CALLBACK_PATH } from "./links";
9import 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.
27WebBrowser.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 */
35export 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. */
56export 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.59const 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 */
69export function redirectUri(): string {
70 return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) });
71}
72
73export 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
79type 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. */
87async 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
95function 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
103export 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 */
114export 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.148function 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. */
161export 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 */
172export 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}