Skip to content
185 linesCodeBlameRaw
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 /** One of the site's JSON routes that answers in its own shape, such as `/-/notify`. */
84 siteRaw<T>(path: string, init?: RequestInit): Promise<T> {
85 return this.json<T>(this.session.server.site + path, init, "site");
86 }
87
88 /** The current access token, refreshed first when it is about to expire. */
89 async token(): Promise<string> {
90 const credentials = this.session.credentials;
91 if (credentials.kind === "oauth" && credentials.expiresAt - REFRESH_EARLY_MS < Date.now()) {
92 return (await this.refreshOnce()).accessToken;
93 }
94 return credentials.accessToken;
95 }
96
97 private refreshOnce(): Promise<Credentials> {
98 const credentials = this.session.credentials;
99 if (credentials.kind !== "oauth") return Promise.resolve(credentials);
100 this.refreshing ??= refresh(this.session.server, credentials)
101 .then(async (next) => {
102 this.session = { ...this.session, credentials: next };
103 await saveCredentials(next);
104 this.session.onCredentials(next);
105 return next;
106 })
107 .catch((error) => {
108 if (error instanceof SignedOut) this.session.onCredentials(null);
109 throw error instanceof SignedOut ? new ApiError("signed_out", error.message, 401) : error;
110 })
111 .finally(() => {
112 this.refreshing = null;
113 });
114 return this.refreshing;
115 }
116
117 private async json<T>(url: string, init: RequestInit | undefined, target: "api" | "site", retried = false): Promise<T> {
118 const token = await this.token();
119 let response: Response;
120 try {
121 response = await fetch(url, {
122 ...init,
123 headers: {
124 accept: "application/json",
125 ...(init?.body ? { "content-type": "application/json" } : {}),
126 ...init?.headers,
127 authorization: `Bearer ${token}`,
128 },
129 });
130 } catch {
131 throw new ApiError("offline", "Could not reach g1t. Check your connection.");
132 }
133 if (response.status === 401) {
134 // An expired or revoked OAuth token gets one refresh; the site refusing
135 // a token it will never take is not that.
136 const refusedBySite = target === "site" && (await this.siteRefusesToken(response));
137 if (refusedBySite) {
138 throw new ApiError(
139 "website_refused",
140 this.session.credentials.kind === "oauth"
141 ? "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."
142 : "This access token cannot use the website. Turn on “Use the website as you” for it in Settings → Access tokens.",
143 401,
144 );
145 }
146 if (!retried && this.session.credentials.kind === "oauth") {
147 await this.refreshOnce();
148 return this.json<T>(url, init, target, true);
149 }
150 this.session.onCredentials(null);
151 throw new ApiError("signed_out", "You were signed out. Sign in again.", 401);
152 }
153 if (response.status === 404) throw new ApiError("not_found", "Not found.", 404);
154 if (response.status === 403) throw new ApiError("forbidden", await messageOf(response, "You do not have access to this."), 403);
155 if (!response.ok) throw new ApiError("failed", await messageOf(response, `g1t answered ${response.status}.`), response.status);
156 return (await response.json()) as T;
157 }
158
159 /**
160 * Whether a 401 from the site is it refusing the token itself (its
161 * `WWW-Authenticate` names `invalid_token`; apps/web lib/website-token.ts).
162 * The token may still be good for the API: check before signing out.
163 */
164 private async siteRefusesToken(response: Response): Promise<boolean> {
165 if (!/invalid_token/.test(response.headers.get("www-authenticate") ?? "")) return false;
166 try {
167 const check = await fetch(`${this.session.server.api}/user`, {
168 headers: { authorization: `Bearer ${this.session.credentials.accessToken}` },
169 });
170 return check.ok;
171 } catch {
172 return false;
173 }
174 }
175}
176
177async function messageOf(response: Response, fallback: string): Promise<string> {
178 try {
179 const body = (await response.clone().json()) as { message?: string; error?: { message?: string }; error_description?: string };
180 return body.message ?? body.error?.message ?? body.error_description ?? fallback;
181 } catch {
182 const text = await response.text().catch(() => "");
183 return text.trim().slice(0, 300) || fallback;
184 }
185}