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 { type Credentials, saveCredentials } from "./credentials"; |
| 2 | import { SignedOut, refresh } from "./oauth"; | |
| 3 | import 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 | ||
| 16 | export 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 | ||
| 29 | export class ApiError extends Error { | |
| 30 | constructor( | |
| 31 | readonly code: ErrorCode, | |
| 32 | message: string, | |
| 33 | readonly status = 0, | |
| 34 | ) { | |
| 35 | super(message); | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | export 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 | ||
| 46 | const REFRESH_EARLY_MS = 60_000; | |
| 47 | ||
| 48 | export 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 | ||
| 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. | 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 | ||
| 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. | 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 | ||
| 177 | async 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 | } |