Skip to content
281 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.

Merge main into Artifacts Phase 21/**
2 * Using the website with an access token: automation driving a browser
3 * (Playwright and the like) sends `Authorization: Bearer g1t_…` on every
4 * request and is signed in as the token's owner for that request alone.
5 * lib/session.server.ts resolves it; these are the rules it follows.
6 *
7 * - Only the `Authorization` header is read, never a query string or a
8 * cookie, and only a person's own token whose owner turned on "Use the
9 * website as you" is accepted (`token.website`, identity's tokens.rs).
10 * No cookie is set and no session is made: each request carries the
11 * token, and expiry, deletion and a workspace revoking it apply at once.
12 * - The header wins over a session cookie on the same request, so a
13 * request is either a token's or a session's, never both.
14 * - Browsers never send the header by themselves, so another site cannot
15 * make one: the same-origin check on form posts (`assertSameOrigin`) is
16 * the same for both and nothing about cookies is relaxed.
17 * - What the token's owner does on the website is theirs, as for a
18 * session, except what needs a real sign-in: tokens, two-factor
19 * authentication, passwords, email addresses, SSH and signing keys,
20 * applications, deleting the account or a workspace, giving a workspace
21 * away, and payment methods ({@link needsRealSignIn}).
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)22 * - A person's own token without "Use the website as you" is still
23 * accepted on a few JSON routes and live sockets, when it holds their
24 * scope: Chat's (`chat:read`, `chat:write`) and the person's push and
25 * notification settings (`notifications:read`, `notifications:write`).
26 * That is how an application signed in with OAuth, such as the phone
27 * app, reaches Chat, which has no API of its own ({@link scopedRoute}).
28 * Only scopes given by name count, not full access. Nowhere else does
29 * such a token sign in.
Merge main into Artifacts Phase 230 * - A token that is not accepted is no one: a page loads signed out, and a
31 * data request or form post is refused with a 401 that says why.
32 */
33
34import type { User, Viewer } from "@g1t/contracts";
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)35import { type Scope, tokenAllows } from "@g1t/contracts/scopes";
Merge main into Artifacts Phase 236
37/**
38 * The page a request is for, as routes match it: decoded, any case, no
39 * doubled or trailing slashes, and a client navigation's `.data` as its
40 * page (as lib/confirm-gate.ts's `pageOf`).
41 */
42function pageOf(pathname: string): string {
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)43 let path = decodedPath(pathname);
Merge main into Artifacts Phase 244 if (path.endsWith(".data")) {
45 path = path.slice(0, -".data".length);
46 if (path === "/_root") path = "/";
47 }
48 if (path.length > 1) path = path.replace(/\/+$/, "");
49 return path || "/";
50}
51
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)52/** A path decoded, in any case, without doubled slashes. */
53function decodedPath(pathname: string): string {
54 let path = pathname;
55 try {
56 path = decodeURIComponent(path);
57 } catch {
58 // Left as it came: routes cannot match a malformed escape either.
59 }
60 return path.toLowerCase().replace(/\/{2,}/g, "/");
61}
62
Merge main into Artifacts Phase 263/** Where the docs explain it. */
64export const WEBSITE_TOKEN_DOCS = "https://docs.g1t.sh/guides/authentication/#use-a-token-on-the-website";
65
66/**
67 * The token in `Authorization: Bearer <token>`, or null when the request
68 * has no such header. Any other scheme is not a token.
69 */
70export function bearerToken(request: Request): string | null {
71 const header = request.headers.get("authorization");
72 if (!header) return null;
73 const match = /^\s*bearer\s+(\S+)\s*$/i.exec(header);
74 return match ? match[1] : null;
75}
76
77/**
78 * The person a token resolved to, when it may use the website: a person
79 * (not a workspace, an agent or a job) whose token has the website
80 * permission. Null otherwise.
81 */
82export function websiteUser(viewer: Viewer): User | null {
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)83 const user = personsToken(viewer);
84 return user?.token?.website ? user : null;
85}
86
87/**
88 * A person resolved from their own token: not a workspace, an agent or
89 * someone acting through one, a workflow job or a deploy key. Null
90 * otherwise, and for a session.
91 */
92function personsToken(viewer: Viewer): User | null {
Merge main into Artifacts Phase 293 if (!viewer || (viewer.kind ?? "user") !== "user" || viewer.acting) return null;
94 const token = viewer.token;
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)95 if (!token || token.job || token.deploy_key) return null;
Merge main into Artifacts Phase 296 return viewer;
97}
98
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)99/** A workspace in a path: any segment but `-`, which is no workspace's name. */
100const WORKSPACE = "(?!-/)[^/]+";
101
102/**
103 * The routes a person's token reaches by its scopes, without the website
104 * permission: each path (decoded, any case, no doubled or trailing
105 * slashes), the method, and the scope it needs. JSON and sockets only,
106 * never a page: a client navigation's `.data` is not one of them, since it
107 * would run the loaders of the pages around the route as well.
108 */
109const SCOPED_ROUTES: { path: RegExp; method: "GET" | "POST"; needs: Scope }[] = [
110 // Chat's JSON: the sidebar, messages, threads, emoji, browsing channels.
111 { path: new RegExp(`^/${WORKSPACE}/-/chat/api$`), method: "GET", needs: "chat:read" },
112 // Sending, editing and deleting, reactions, read marks, cards' actions.
113 { path: new RegExp(`^/${WORKSPACE}/-/chat/api$`), method: "POST", needs: "chat:write" },
114 // A person's card in Chat.
115 { path: new RegExp(`^/${WORKSPACE}/-/chat/person/[^/]+$`), method: "GET", needs: "chat:read" },
116 // Tickets for Chat's live socket and the person's feed (lib/socket-ticket.ts).
117 { path: /^\/-\/live\/ticket$/, method: "GET", needs: "chat:read" },
118 // Push registration and notification settings (routes/notify/api.ts).
119 { path: /^\/-\/notify$/, method: "GET", needs: "notifications:read" },
120 { path: /^\/-\/notify$/, method: "POST", needs: "notifications:write" },
121];
122
123/**
124 * The scope a token needs to reach this request without the website
125 * permission, or null when such a token reaches nothing here.
126 */
127export function scopedRoute(pathname: string, method: string): Scope | null {
128 let path = decodedPath(pathname);
129 if (path.length > 1) path = path.replace(/\/+$/, "");
130 if (path.endsWith(".data")) return null;
131 const wanted = method.toUpperCase();
132 return SCOPED_ROUTES.find((route) => route.method === wanted && route.path.test(path))?.needs ?? null;
133}
134
135/**
136 * The person a token resolved to when it may make this request by its
137 * scopes alone ({@link scopedRoute}): their own token, holding the scope
138 * the route needs, by name (see {@link grants}). Null otherwise.
139 */
140export function scopedUser(viewer: Viewer, pathname: string, method: string): User | null {
141 const needs = scopedRoute(pathname, method);
142 const user = needs ? personsToken(viewer) : null;
143 return user && needs && grants(user.token?.scopes, needs) ? user : null;
144}
145
146/**
147 * Whether a token was given `needed` by name. A full-access token (no
148 * scopes, or made before scopes) is not: those were made for git, the API
149 * and MCP, and reading someone's conversations is not what they were
150 * given, so they reach Chat only with "Use the website as you".
151 */
152function grants(scopes: readonly string[] | null | undefined, needed: Scope): boolean {
153 return Array.isArray(scopes) && tokenAllows(scopes, needed);
154}
155
156/**
157 * The person a token may open a live socket as (`path` is a
158 * lib/socket-ticket.ts `socketPath`): a token with the website permission
159 * opens any of them; a person's token with `chat:read` opens Chat's live
160 * socket and the person's feed (`/-/live`), never an artifact's.
161 */
162export function socketUser(viewer: Viewer, path: string): User | null {
163 const website = websiteUser(viewer);
164 if (website) return website;
165 const user = personsToken(viewer);
166 if (!user || !grants(user.token?.scopes, "chat:read")) return null;
167 return path === "/-/live" || new RegExp(`^/${WORKSPACE}/-/chat/live$`).test(path) ? user : null;
168}
169
Merge main into Artifacts Phase 2170/** Why a token was not accepted, for the 401. */
171export const TOKEN_REFUSED =
172 "This access token cannot be used on the website: it is not valid, has expired, or does not have “Use the website as you” turned on. " +
173 `See ${WEBSITE_TOKEN_DOCS}`;
174
175/** The `WWW-Authenticate` header for a refused token. */
176export const TOKEN_CHALLENGE = 'Bearer realm="g1t", error="invalid_token"';
177
178/** Pages a token never opens, whatever the method. */
179const ALWAYS = [
180 // Your tokens, two-factor authentication, emails, keys, the account
181 // itself (its password and deleting it), applications you let in, and
182 // how you sign in.
183 /^\/settings\/(?:tokens|two-factor|emails|keys|account|applications|github)(?:\/|$)/,
184 // Letting a device or an application in makes a token.
185 /^\/(?:device|oauth\/authorize|auth\/github)(?:\/|$)/,
186 // A workspace's own tokens, and its rules for and approvals of members' tokens.
187 /^\/[^/]+\/-\/(?:tokens|personal-access-tokens)(?:\/|$)/,
188];
189
190/** Form posts a token never makes: a page, the field that names the change, and the changes. */
191const CHANGES: { page: RegExp; field: string; values: string[] }[] = [
192 // Deleting a workspace.
193 { page: /^\/[^/]+\/-\/settings$/, field: "intent", values: ["delete"] },
194 // Giving a workspace to another owner.
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.195 { page: /^\/[^/]+\/-\/(?:members|people)$/, field: "action", values: ["transfer"] },
Merge main into Artifacts Phase 2196 // Payment methods: the card on file, and the payment pages that take one.
197 { page: /^\/[^/]+\/-\/billing$/, field: "intent", values: ["portal", "card-check", "subscribe", "buy-ai-credit"] },
198];
199
200/** Whether a page opens nothing for a token whatever is posted to it. */
201export function alwaysNeedsSignIn(pathname: string): boolean {
202 const page = pageOf(pathname);
203 return ALWAYS.some((pattern) => pattern.test(page));
204}
205
206/**
207 * Whether a request needs a real sign-in rather than a token. `form` is
208 * the posted form, read only for the few pages where one change of many
209 * does (null for none, or when it could not be read).
210 */
211export function needsRealSignIn(pathname: string, method: string, form: { get(name: string): unknown } | null): boolean {
212 if (alwaysNeedsSignIn(pathname)) return true;
213 if (method === "GET" || method === "HEAD" || !form) return false;
214 const page = pageOf(pathname);
215 return CHANGES.some((change) => change.page.test(page) && change.values.includes(String(form.get(change.field) ?? "")));
216}
217
218/** Whether a posted form must be read to decide: a form post to one of {@link CHANGES}' pages. */
219export function readsForm(pathname: string, method: string): boolean {
220 if (method === "GET" || method === "HEAD") return false;
221 const page = pageOf(pathname);
222 return CHANGES.some((change) => change.page.test(page));
223}
224
225/** What a token is told on a page that needs a real sign-in. */
226export type NeedsSignIn = { needs_sign_in: true; message: string };
227
228export const NEEDS_SIGN_IN: NeedsSignIn = {
229 needs_sign_in: true,
230 message:
231 "You are using g1t with an access token. Tokens, two-factor authentication, your password, email addresses and keys, " +
232 "deleting an account or a workspace, giving a workspace away, and payment methods need you to sign in on g1t.sh yourself.",
233};
234
235/** Whether an error's data is {@link NEEDS_SIGN_IN}, for the error page. */
236export function isNeedsSignIn(value: unknown): value is NeedsSignIn {
237 return typeof value === "object" && value !== null && (value as { needs_sign_in?: unknown }).needs_sign_in === true;
238}
239
240/** Whether a request wants data (a loader's `.data` or a form post) rather than a page. */
241export function wantsData(pathname: string, method: string): boolean {
242 return pathname.endsWith(".data") || (method !== "GET" && method !== "HEAD");
243}
244
245/** What to do with a request, as far as a token on it goes. */
246export type TokenVerdict =
247 /** No token: the session cookie, if any, decides. */
248 | { kind: "none" }
249 /** Signed in as the token's owner, for this request. */
250 | { kind: "signed-in"; user: User }
251 /** A page with a token not accepted: shown signed out, with a challenge header. */
252 | { kind: "signed-out" }
253 /** Refused: a 401 for a token not accepted, a 403 for what needs a real sign-in. */
254 | { kind: "refused"; status: 401; body: string }
255 | { kind: "refused"; status: 403; body: NeedsSignIn };
256
257/**
258 * Decides a request's token. `lookup` resolves a token to whoever it
259 * names (identity's `user_for_access_token`), checked on every request.
260 */
261export async function tokenVerdict(request: Request, lookup: (token: string) => Promise<Viewer>): Promise<TokenVerdict> {
262 const token = bearerToken(request);
263 if (token === null) return { kind: "none" };
264 const { pathname } = new URL(request.url);
265 const method = request.method.toUpperCase();
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)266 const viewer = token.startsWith("g1t_") ? await lookup(token) : null;
267 // The website permission opens the site; a token's scopes open only the
268 // few routes that need them.
269 const user = websiteUser(viewer) ?? scopedUser(viewer, pathname, method);
Merge main into Artifacts Phase 2270 if (!user) return wantsData(pathname, method) ? { kind: "refused", status: 401, body: TOKEN_REFUSED } : { kind: "signed-out" };
271 let form: { get(name: string): unknown } | null = null;
272 if (readsForm(pathname, method)) {
273 try {
274 form = await request.clone().formData();
275 } catch {
276 form = null;
277 }
278 }
279 if (needsRealSignIn(pathname, method, form)) return { kind: "refused", status: 403, body: NEEDS_SIGN_IN };
280 return { kind: "signed-in", user };
281}

This file's history is long; its oldest lines are credited to the oldest commit read.