Skip to content
180 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 { type Credentials, saveCredentials } from "./credentials";
2import { SignedOut, refresh } from "./oauth";
3import type { Server } from "./server";
4
5/**
6 * Every request the app makes, with its credentials: `api` for the REST
7 * API (api.g1t.sh), `site` for the site's own JSON routes, which Chat
8 * lives behind (`/<workspace>/-/chat/api`). Both take
9 * `Authorization: Bearer g1t_…`.
10 *
11 * An OAuth access token is refreshed a minute before it expires, and on a
12 * 401 once; requests that need a refresh at the same time share it,
13 * because a refresh token works once.
14 */
15
16export type ErrorCode =
17 /** Signed out: the session is over and the app returns to sign-in. */
18 | "signed_out"
19 /**
20 * The site refused the token: an OAuth token, or a personal one without
21 * "Use the website as you". The API still works; Chat does not.
22 */
23 | "website_refused"
24 | "not_found"
25 | "forbidden"
26 | "offline"
27 | "failed";
28
29export class ApiError extends Error {
30 constructor(
31 readonly code: ErrorCode,
32 message: string,
33 readonly status = 0,
34 ) {
35 super(message);
36 }
37}
38
39export type Session = {
40 server: Server;
41 credentials: Credentials;
42 /** Told when credentials change (a refresh) or end (signed out). */
43 onCredentials: (next: Credentials | null) => void;
44};
45
46const REFRESH_EARLY_MS = 60_000;
47
48export class Client {
49 private refreshing: Promise<Credentials> | null = null;
50
51 constructor(private session: Session) {}
52
53 get server(): Server {
54 return this.session.server;
55 }
56
57 get credentials(): Credentials {
58 return this.session.credentials;
59 }
60
61 /** A REST API call: `client.api<Repo[]>("/repos")`. */
62 api<T>(path: string, init?: RequestInit): Promise<T> {
63 return this.json<T>(this.session.server.api + path, init, "api");
64 }
65
66 /**
67 * One of the site's JSON routes. They answer `{ ok, value }` or
68 * `{ ok: false, error }`, the services' own shape, which this unwraps.
69 */
70 async site<T>(path: string, init?: RequestInit): Promise<T> {
71 const answer = await this.json<{ ok: true; value: T } | { ok: false; error: { code: string; message: string } }>(
72 this.session.server.site + path,
73 init,
74 "site",
75 );
76 if (!answer.ok) {
77 const code: ErrorCode = answer.error.code === "not_found" ? "not_found" : answer.error.code === "forbidden" ? "forbidden" : "failed";
78 throw new ApiError(code, answer.error.message);
79 }
80 return answer.value;
81 }
82
83 /** The current access token, refreshed first when it is about to expire. */
84 async token(): Promise<string> {
85 const credentials = this.session.credentials;
86 if (credentials.kind === "oauth" && credentials.expiresAt - REFRESH_EARLY_MS < Date.now()) {
87 return (await this.refreshOnce()).accessToken;
88 }
89 return credentials.accessToken;
90 }
91
92 private refreshOnce(): Promise<Credentials> {
93 const credentials = this.session.credentials;
94 if (credentials.kind !== "oauth") return Promise.resolve(credentials);
95 this.refreshing ??= refresh(this.session.server, credentials)
96 .then(async (next) => {
97 this.session = { ...this.session, credentials: next };
98 await saveCredentials(next);
99 this.session.onCredentials(next);
100 return next;
101 })
102 .catch((error) => {
103 if (error instanceof SignedOut) this.session.onCredentials(null);
104 throw error instanceof SignedOut ? new ApiError("signed_out", error.message, 401) : error;
105 })
106 .finally(() => {
107 this.refreshing = null;
108 });
109 return this.refreshing;
110 }
111
112 private async json<T>(url: string, init: RequestInit | undefined, target: "api" | "site", retried = false): Promise<T> {
113 const token = await this.token();
114 let response: Response;
115 try {
116 response = await fetch(url, {
117 ...init,
118 headers: {
119 accept: "application/json",
120 ...(init?.body ? { "content-type": "application/json" } : {}),
121 ...init?.headers,
122 authorization: `Bearer ${token}`,
123 },
124 });
125 } catch {
126 throw new ApiError("offline", "Could not reach g1t. Check your connection.");
127 }
128 if (response.status === 401) {
129 // An expired or revoked OAuth token gets one refresh; the site refusing
130 // a token it will never take is not that.
131 const refusedBySite = target === "site" && (await this.siteRefusesToken(response));
132 if (refusedBySite) {
133 throw new ApiError(
134 "website_refused",
135 this.session.credentials.kind === "oauth"
136 ? "Chat is not open to the app's sign-in on this g1t yet. Sign in with an access token that can use the website, from You → Sign in another way."
137 : "This access token cannot use the website. Turn on “Use the website as you” for it in Settings → Access tokens.",
138 401,
139 );
140 }
141 if (!retried && this.session.credentials.kind === "oauth") {
142 await this.refreshOnce();
143 return this.json<T>(url, init, target, true);
144 }
145 this.session.onCredentials(null);
146 throw new ApiError("signed_out", "You were signed out. Sign in again.", 401);
147 }
148 if (response.status === 404) throw new ApiError("not_found", "Not found.", 404);
149 if (response.status === 403) throw new ApiError("forbidden", await messageOf(response, "You do not have access to this."), 403);
150 if (!response.ok) throw new ApiError("failed", await messageOf(response, `g1t answered ${response.status}.`), response.status);
151 return (await response.json()) as T;
152 }
153
154 /**
155 * Whether a 401 from the site is it refusing the token itself (its
156 * `WWW-Authenticate` names `invalid_token`; apps/web lib/website-token.ts).
157 * The token may still be good for the API: check before signing out.
158 */
159 private async siteRefusesToken(response: Response): Promise<boolean> {
160 if (!/invalid_token/.test(response.headers.get("www-authenticate") ?? "")) return false;
161 try {
162 const check = await fetch(`${this.session.server.api}/user`, {
163 headers: { authorization: `Bearer ${this.session.credentials.accessToken}` },
164 });
165 return check.ok;
166 } catch {
167 return false;
168 }
169 }
170}
171
172async function messageOf(response: Response, fallback: string): Promise<string> {
173 try {
174 const body = (await response.clone().json()) as { message?: string; error?: { message?: string }; error_description?: string };
175 return body.message ?? body.error?.message ?? body.error_description ?? fallback;
176 } catch {
177 const text = await response.text().catch(() => "");
178 return text.trim().slice(0, 300) || fallback;
179 }
180}