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}). | |
| 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 2 | 30 | * - 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 | ||
| 34 | import type { User, Viewer } from "@g1t/contracts"; | |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 35 | import { type Scope, tokenAllows } from "@g1t/contracts/scopes"; |
| Merge main into Artifacts Phase 2 | 36 | |
| 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 | */ | |
| 42 | function 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 2 | 44 | 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. */ |
| 53 | function 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 2 | 63 | /** Where the docs explain it. */ |
| 64 | export 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 | */ | |
| 70 | export 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 | */ | |
| 82 | export 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 | */ | |
| 92 | function personsToken(viewer: Viewer): User | null { | |
| Merge main into Artifacts Phase 2 | 93 | 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 2 | 96 | 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. */ |
| 100 | const 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 | */ | |
| 109 | const 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 | */ | |
| 127 | export 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 | */ | |
| 140 | export 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 | */ | |
| 152 | function 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 | */ | |
| 162 | export 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 2 | 170 | /** Why a token was not accepted, for the 401. */ |
| 171 | export 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. */ | |
| 176 | export const TOKEN_CHALLENGE = 'Bearer realm="g1t", error="invalid_token"'; | |
| 177 | ||
| 178 | /** Pages a token never opens, whatever the method. */ | |
| 179 | const 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. */ | |
| 191 | const 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 2 | 196 | // 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. */ | |
| 201 | export 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 | */ | |
| 211 | export 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. */ | |
| 219 | export 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. */ | |
| 226 | export type NeedsSignIn = { needs_sign_in: true; message: string }; | |
| 227 | ||
| 228 | export 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. */ | |
| 236 | export 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. */ | |
| 241 | export 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. */ | |
| 246 | export 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 | */ | |
| 261 | export 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 2 | 270 | 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.