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.

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}).
22 * - A token that is not accepted is no one: a page loads signed out, and a
23 * data request or form post is refused with a 401 that says why.
24 */
25
26import type { User, Viewer } from "@g1t/contracts";
27
28/**
29 * The page a request is for, as routes match it: decoded, any case, no
30 * doubled or trailing slashes, and a client navigation's `.data` as its
31 * page (as lib/confirm-gate.ts's `pageOf`).
32 */
33function pageOf(pathname: string): string {
34 let path = pathname;
35 try {
36 path = decodeURIComponent(path);
37 } catch {
38 // Left as it came: routes cannot match a malformed escape either.
39 }
40 path = path.toLowerCase().replace(/\/{2,}/g, "/");
41 if (path.endsWith(".data")) {
42 path = path.slice(0, -".data".length);
43 if (path === "/_root") path = "/";
44 }
45 if (path.length > 1) path = path.replace(/\/+$/, "");
46 return path || "/";
47}
48
49/** Where the docs explain it. */
50export const WEBSITE_TOKEN_DOCS = "https://docs.g1t.sh/guides/authentication/#use-a-token-on-the-website";
51
52/**
53 * The token in `Authorization: Bearer <token>`, or null when the request
54 * has no such header. Any other scheme is not a token.
55 */
56export function bearerToken(request: Request): string | null {
57 const header = request.headers.get("authorization");
58 if (!header) return null;
59 const match = /^\s*bearer\s+(\S+)\s*$/i.exec(header);
60 return match ? match[1] : null;
61}
62
63/**
64 * The person a token resolved to, when it may use the website: a person
65 * (not a workspace, an agent or a job) whose token has the website
66 * permission. Null otherwise.
67 */
68export function websiteUser(viewer: Viewer): User | null {
69 if (!viewer || (viewer.kind ?? "user") !== "user" || viewer.acting) return null;
70 const token = viewer.token;
71 if (!token?.website || token.job || token.deploy_key) return null;
72 return viewer;
73}
74
75/** Why a token was not accepted, for the 401. */
76export const TOKEN_REFUSED =
77 "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. " +
78 `See ${WEBSITE_TOKEN_DOCS}`;
79
80/** The `WWW-Authenticate` header for a refused token. */
81export const TOKEN_CHALLENGE = 'Bearer realm="g1t", error="invalid_token"';
82
83/** Pages a token never opens, whatever the method. */
84const ALWAYS = [
85 // Your tokens, two-factor authentication, emails, keys, the account
86 // itself (its password and deleting it), applications you let in, and
87 // how you sign in.
88 /^\/settings\/(?:tokens|two-factor|emails|keys|account|applications|github)(?:\/|$)/,
89 // Letting a device or an application in makes a token.
90 /^\/(?:device|oauth\/authorize|auth\/github)(?:\/|$)/,
91 // A workspace's own tokens, and its rules for and approvals of members' tokens.
92 /^\/[^/]+\/-\/(?:tokens|personal-access-tokens)(?:\/|$)/,
93];
94
95/** Form posts a token never makes: a page, the field that names the change, and the changes. */
96const CHANGES: { page: RegExp; field: string; values: string[] }[] = [
97 // Deleting a workspace.
98 { page: /^\/[^/]+\/-\/settings$/, field: "intent", values: ["delete"] },
99 // 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.100 { page: /^\/[^/]+\/-\/(?:members|people)$/, field: "action", values: ["transfer"] },
Merge main into Artifacts Phase 2101 // Payment methods: the card on file, and the payment pages that take one.
102 { page: /^\/[^/]+\/-\/billing$/, field: "intent", values: ["portal", "card-check", "subscribe", "buy-ai-credit"] },
103];
104
105/** Whether a page opens nothing for a token whatever is posted to it. */
106export function alwaysNeedsSignIn(pathname: string): boolean {
107 const page = pageOf(pathname);
108 return ALWAYS.some((pattern) => pattern.test(page));
109}
110
111/**
112 * Whether a request needs a real sign-in rather than a token. `form` is
113 * the posted form, read only for the few pages where one change of many
114 * does (null for none, or when it could not be read).
115 */
116export function needsRealSignIn(pathname: string, method: string, form: { get(name: string): unknown } | null): boolean {
117 if (alwaysNeedsSignIn(pathname)) return true;
118 if (method === "GET" || method === "HEAD" || !form) return false;
119 const page = pageOf(pathname);
120 return CHANGES.some((change) => change.page.test(page) && change.values.includes(String(form.get(change.field) ?? "")));
121}
122
123/** Whether a posted form must be read to decide: a form post to one of {@link CHANGES}' pages. */
124export function readsForm(pathname: string, method: string): boolean {
125 if (method === "GET" || method === "HEAD") return false;
126 const page = pageOf(pathname);
127 return CHANGES.some((change) => change.page.test(page));
128}
129
130/** What a token is told on a page that needs a real sign-in. */
131export type NeedsSignIn = { needs_sign_in: true; message: string };
132
133export const NEEDS_SIGN_IN: NeedsSignIn = {
134 needs_sign_in: true,
135 message:
136 "You are using g1t with an access token. Tokens, two-factor authentication, your password, email addresses and keys, " +
137 "deleting an account or a workspace, giving a workspace away, and payment methods need you to sign in on g1t.sh yourself.",
138};
139
140/** Whether an error's data is {@link NEEDS_SIGN_IN}, for the error page. */
141export function isNeedsSignIn(value: unknown): value is NeedsSignIn {
142 return typeof value === "object" && value !== null && (value as { needs_sign_in?: unknown }).needs_sign_in === true;
143}
144
145/** Whether a request wants data (a loader's `.data` or a form post) rather than a page. */
146export function wantsData(pathname: string, method: string): boolean {
147 return pathname.endsWith(".data") || (method !== "GET" && method !== "HEAD");
148}
149
150/** What to do with a request, as far as a token on it goes. */
151export type TokenVerdict =
152 /** No token: the session cookie, if any, decides. */
153 | { kind: "none" }
154 /** Signed in as the token's owner, for this request. */
155 | { kind: "signed-in"; user: User }
156 /** A page with a token not accepted: shown signed out, with a challenge header. */
157 | { kind: "signed-out" }
158 /** Refused: a 401 for a token not accepted, a 403 for what needs a real sign-in. */
159 | { kind: "refused"; status: 401; body: string }
160 | { kind: "refused"; status: 403; body: NeedsSignIn };
161
162/**
163 * Decides a request's token. `lookup` resolves a token to whoever it
164 * names (identity's `user_for_access_token`), checked on every request.
165 */
166export async function tokenVerdict(request: Request, lookup: (token: string) => Promise<Viewer>): Promise<TokenVerdict> {
167 const token = bearerToken(request);
168 if (token === null) return { kind: "none" };
169 const { pathname } = new URL(request.url);
170 const method = request.method.toUpperCase();
171 const user = token.startsWith("g1t_") ? websiteUser(await lookup(token)) : null;
172 if (!user) return wantsData(pathname, method) ? { kind: "refused", status: 401, body: TOKEN_REFUSED } : { kind: "signed-out" };
173 let form: { get(name: string): unknown } | null = null;
174 if (readsForm(pathname, method)) {
175 try {
176 form = await request.clone().formData();
177 } catch {
178 form = null;
179 }
180 }
181 if (needsRealSignIn(pathname, method, form)) return { kind: "refused", status: 403, body: NEEDS_SIGN_IN };
182 return { kind: "signed-in", user };
183}

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