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 2 | 1 | /** |
| 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 | ||
| 26 | import 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 | */ | |
| 33 | function 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. */ | |
| 50 | export 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 | */ | |
| 56 | export 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 | */ | |
| 68 | export 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. */ | |
| 76 | export 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. */ | |
| 81 | export const TOKEN_CHALLENGE = 'Bearer realm="g1t", error="invalid_token"'; | |
| 82 | ||
| 83 | /** Pages a token never opens, whatever the method. */ | |
| 84 | const 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. */ | |
| 96 | const 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 2 | 101 | // 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. */ | |
| 106 | export 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 | */ | |
| 116 | export 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. */ | |
| 124 | export 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. */ | |
| 131 | export type NeedsSignIn = { needs_sign_in: true; message: string }; | |
| 132 | ||
| 133 | export 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. */ | |
| 141 | export 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. */ | |
| 146 | export 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. */ | |
| 151 | export 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 | */ | |
| 166 | export 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.