Skip to content

Commit

Merge branch 'worktree-agent-ad4439ce85a91ecb4' into integrate

syntaqxcommitted Parentsa7c34f1efdfeeeBrowse files
32 files+776−310/32 viewed
+1−1
7676 | Actor | Who did it: a person, an agent, or a workspace token. |
7777 | On behalf of | For an agent, the person it worked for: `g1t on behalf of syntaqx`. |
7878 | Run | The agent run, with its kind: `implement`, `review`, `update` and so on; or the workflow run whose job's token did it, as `workflow_job`. |
79−| Credential | The id of the token used. |
79+| Credential | The id of the token used: on the API, the MCP server or git, or on g1t.sh by a token [used on the website](/guides/authentication/#use-a-token-on-the-website). Empty for a person signed in on g1t.sh. |
8080 | Action | The API or MCP operation, such as `create_issue`, or `git.push` and `git.fetch`. |
8181 | Target | The repository, the issue or pull request number, and for git the refs it moved. |
8282 | Outcome | `allowed` or `denied`. |
+102−4
577577
578578 ## Access tokens
579579
580−A token stands in for your password everywhere outside the website:
580+A token stands in for your password everywhere outside the website, and on
581+the website too when you turn that on for it:
581582
582583 | Where | How to send it |
583584 | --- | --- |
584585 | git | As the password, with your username. |
585586 | API | `Authorization: Bearer g1t_…` |
586587 | MCP | The same header, set when you add the server. |
588+| The website | The same header on every request, from automation that drives a browser. Only a token with **Use the website as you** turned on. See [use a token on the website](#use-a-token-on-the-website). |
587589
588590 A token is shown once, when it is created; g1t stores only a hash of it.
589591 If you lose one, delete it and create another. Delete a token the moment
627629 5. Under **Permissions**, set each resource the token needs to a level.
628630 **Read only**, **Agent** and **CI** fill in a [preset](#presets);
629631 **Clear** sets everything back to no access.
630−6. Select **Generate token**, and copy it. It is not shown again.
632+6. Leave **Use the website as you**, under **Website**, off unless the
633+ token is for automation that drives a browser. See
634+ [use a token on the website](#use-a-token-on-the-website).
635+7. Select **Generate token**, and copy it. It is not shown again.
631636
632637 When you make a token for one workspace that
633638 [requires approval](#a-workspaces-rules-for-tokens), and you are not one of
641646 The list under Settings → Access tokens shows each token's name, status
642647 (pending, denied or revoked, with the owner's note), where it reaches, its
643648 permissions, and when it was made, last used and expires. Select a token to
644−open its page, where you can change its name, description, repositories
645−and permissions, and select **Save changes**. The token itself stays the
649+open its page, where you can change its name, description, repositories,
650+permissions and **Use the website as you**, and select **Save changes**.
651+A token that can use the website is marked **Uses the website**. The token
652+itself stays the
646653 same; the change applies from its next request. Widening a token made for
647654 a workspace that requires approval asks its owners again. Where it reaches
648655 and when it expires cannot change; make a new token instead.
649656
650657 **Delete token**, at the bottom of its page, stops it working at once.
651658
659+### Use a token on the website
660+
661+Automation that drives a browser, such as end-to-end tests or an agent
662+checking how a page looks, can use g1t.sh as you with an access token, so it
663+never types your password or a two-factor code. Each request it makes
664+carries the token in the `Authorization` header; no cookie is set and no
665+session is started.
666+
667+1. Open [Settings → Access tokens](https://g1t.sh/settings/tokens) and
668+ select **New token**, or open a token you have.
669+2. Give it an expiration, and the permissions it needs for git, the API and
670+ MCP, if any.
671+3. Under **Website**, tick **Use the website as you**. It is off unless you
672+ tick it, and a workspace's own token cannot have it.
673+4. Select **Generate token** (or **Save changes**), and keep the token in a
674+ file only the automation can read.
675+5. Send `Authorization: Bearer g1t_…` on every request to g1t.sh.
676+
677+With [Playwright](https://playwright.dev), set the header on the browser
678+context, reading the token from a file so it is never printed:
679+
680+```js
681+import { readFileSync } from "node:fs";
682+import { chromium } from "playwright";
683+
684+const token = readFileSync(process.env.G1T_TOKEN_FILE, "utf8").trim();
685+const browser = await chromium.launch();
686+const context = await browser.newContext({
687+ extraHTTPHeaders: { authorization: `Bearer ${token}` },
688+});
689+const page = await context.newPage();
690+await page.goto("https://g1t.sh/acme/rocket/pulls");
691+await page.screenshot({ path: "pulls.png", fullPage: true });
692+await browser.close();
693+```
694+
695+`extraHTTPHeaders` sends the header with every request the page makes,
696+including to other addresses it loads files from. To send it to g1t.sh
697+only, add it per request instead:
698+
699+```js
700+const context = await browser.newContext();
701+await context.route("https://g1t.sh/**", (route) =>
702+ route.continue({ headers: { ...route.request().headers(), authorization: `Bearer ${token}` } }),
703+);
704+```
705+
706+Any HTTP client works the same way:
707+
708+```sh
709+curl -H "Authorization: Bearer $(cat ~/.config/g1t/website-token)" https://g1t.sh/acme/rocket/pulls
710+```
711+
712+On the website, the token acts as you in the workspaces it
713+[reaches](#where-a-token-reaches). Its permissions are made for git, the API
714+and MCP, and the website does not hold it to them: treat it as able to do
715+anything there that you can. Keep it as safe as your password, and give it
716+an expiration.
717+
718+- **Only the header counts.** A token in a query string or a cookie is
719+ ignored. A request with the header is the token's, even if it also has a
720+ session cookie.
721+- **Checked on every request.** Deleting the token, its expiry, or a
722+ workspace [revoking it](#a-workspaces-rules-for-tokens) stops it at once.
723+- **A token that is not accepted is no one.** One that is not valid, has
724+ expired, or does not have **Use the website as you** loads pages as
725+ someone signed out, with a `WWW-Authenticate` header saying the token was
726+ refused; the data requests and form posts pages make answer `401`.
727+- **Form posts need nothing more.** Browsers never send the header by
728+ themselves, so a post with it needs no other proof it came from g1t.sh.
729+ A post from another site is still refused.
730+- **Limits follow the token**: 1,000 requests a minute, as on the API. See
731+ [rate limits](/reference/rate-limits/).
732+- **The audit log names it.** A change made this way is recorded as yours,
733+ with the token's id under **Credential**. See [the audit log](/guides/audit-log/).
734+
735+Some things always need you to sign in on g1t.sh yourself. With a token,
736+these pages answer **This needs you to sign in** (`403`):
737+
738+| What | Where |
739+| --- | --- |
740+| Access tokens, yours and a workspace's, and a workspace's rules for and approvals of members' tokens | Settings → Access tokens; a workspace's Settings → Access tokens and Personal access tokens |
741+| Two-factor authentication | Settings → Two-factor authentication |
742+| Your username, and deleting your account | Settings → Account |
743+| Email addresses, which reset your password | Settings → Emails |
744+| SSH keys | Settings → SSH keys |
745+| Applications you signed in to, and signing in with GitHub | Settings → Connected applications, Settings → GitHub |
746+| Letting a device or an application sign in | `g1t.sh/device`, `g1t.sh/oauth/authorize` |
747+| Deleting a workspace, and giving it to another owner | A workspace's Settings and People |
748+| Payment methods: the billing portal, adding a card, subscribing and buying AI credit | A workspace's Billing |
749+
652750 ### Permissions
653751
654752 A permission is a resource and a level. A higher level includes the lower
+2−1
106106 </Card>
107107 <Card title="Everything has an API" icon="book-open">
108108 Repositories, issues, pull requests, agents and workflows are all a [REST endpoint](/reference/api/) and an [MCP tool action](/reference/mcp/) away, with
109− [webhooks](/guides/webhooks/) for every event.
109+ [webhooks](/guides/webhooks/) for every event. Tests that drive a browser
110+ [use the website with a token](/guides/authentication/#use-a-token-on-the-website).
110111 </Card>
111112 </CardGrid>
112113
+1−0
2020 | Git over HTTPS, without credentials | Client IP address | 120 |
2121 | Anonymous clones of one repository that are not cached | Repository | 120 |
2222 | Pages on g1t.sh, signed in | Session | 1,200 |
23+| Pages on g1t.sh, [with a token](/guides/authentication/#use-a-token-on-the-website) | Token | 1,000 |
2324 | Pages on g1t.sh, signed out | Client IP address | 600 |
2425 | Archive downloads, workflow run pages, logs and search, signed out | Client IP address | 30 |
2526 | [Raw files](/guides/git/#raw-files) on g1tusercontent.com | Client IP address, together with pages signed out | 600 |
+35−1
2828 policyNote,
2929 tokenPermissions,
3030 } from "../lib/access-tokens";
31−import { Checkbox } from "./ui/checkbox";
31+import { Checkbox, CheckboxOption } from "./ui/checkbox";
3232 import { Hint } from "./ui/hint";
3333 import { RadioGroup, RadioOption } from "./ui/radio-group";
3434 import { SelectField } from "./ui/select";
214214 const [levels, setLevels] = useState<Permissions>(() =>
215215 editing ? tokenPermissions(editing) : permissionsOf(presetScopes(preset) ?? null),
216216 );
217+ const [website, setWebsite] = useState<boolean>(editing?.website ?? false);
217218
218219 // The rules of the workspaces it would reach decide how long it may last.
219220 const reached = workspaceOwned ? [] : one ? [chosen] : reach === ALL_WORKSPACES ? workspaces : [];
387388 </p>
388389 )}
389390 </section>
391+
392+ {!workspaceOwned && (
393+ <section className="space-y-3">
394+ <div>
395+ <h3 className="text-sm font-medium text-fg">Website</h3>
396+ <p className="mt-0.5 text-xs text-faint">For automation that drives a browser, such as end-to-end tests.</p>
397+ </div>
398+ <CheckboxOption
399+ id="token-website"
400+ name="website"
401+ value="on"
402+ checked={website}
403+ onCheckedChange={(on) => setWebsite(on === true)}
404+ className="rounded-md border border-line px-3 py-2.5"
405+ labelClassName="font-medium"
406+ label="Use the website as you"
407+ description={
408+ <>
409+ Sent as <code className="font-mono">Authorization: Bearer</code> on each request, it signs g1t.sh in as you
410+ without a password or a two-factor code. Tokens, two-factor authentication, your password, email addresses,
411+ keys, deleting your account or a workspace, giving a workspace away and payment methods still need you to sign
412+ in.
413+ </>
414+ }
415+ />
416+ {website && (
417+ <Note tone="warn">
418+ The website does not hold this token to its permissions above: treat it as able to do anything you can in
419+ the workspaces it reaches. Keep it as safe as your password, and give it an expiration.
420+ </Note>
421+ )}
422+ </section>
423+ )}
390424 </div>
391425 );
392426 }
+1−0
7777 <>
7878 {badge && <Badge tone={badge.tone}>{badge.label}</Badge>}
7979 {token.workspaceOwned && <Badge tone={token.admin ? "danger" : "neutral"}>{token.admin ? "Admin" : "Write"}</Badge>}
80+ {token.website && <Badge tone="warn">Uses the website</Badge>}
8081 </>
8182 );
8283 }
+16−0
165165 assert.deepEqual(changesTo(token, { ...same, repositories: ["acme/web", "acme/api"] }), { repositorySelection: "selected", repositories: ["acme/web", "acme/api"] });
166166 assert.deepEqual(changesTo(token, { ...same, repositorySelection: "all", repositories: [] }), { repositorySelection: "all" });
167167 assert.deepEqual(changesTo(token, { ...same, name: "release", description: "ships" }), { name: "release", description: "ships" });
168+ assert.deepEqual(changesTo(token, { ...same, website: true }), { website: true });
169+ assert.deepEqual(changesTo({ ...token, website: true }, { ...same, website: false }), { website: false });
170+ assert.deepEqual(changesTo({ ...token, website: true }, { ...same, website: true }), {});
171+});
172+
173+test("using the website is off unless checked, and never on a workspace's token", () => {
174+ const base = { name: "e2e", workspace: "*", expires: "7", "perm.repo": "read" };
175+ const off = tokenFromForm(form(base));
176+ assert.ok(off.ok);
177+ assert.equal(off.value.website, false);
178+ const on = tokenFromForm(form({ ...base, website: "on" }));
179+ assert.ok(on.ok);
180+ assert.equal(on.value.website, true);
181+ const workspace = tokenFromForm(form({ ...base, website: "on" }), { workspaceOwned: true, owner: "acme" });
182+ assert.ok(workspace.ok);
183+ assert.equal(workspace.value.website, false);
168184 });
+5−1
99 * The form posts `name`, `description`, `expires` (days, or `never`),
1010 * `workspace` (`*` for every workspace you belong to, `-` for none, or a
1111 * slug), `repository_selection`, one `repo` per chosen repository, and
12− * `perm.<resource>` for each resource's level. Every field is a form field,
12+ * `perm.<resource>` for each resource's level, and `website` when a personal
13+ * token may use the website as you. Every field is a form field,
1314 * so the form posts the same with or without JavaScript.
1415 */
1516
180181 repositorySelection,
181182 repositories: repositorySelection === "selected" ? repositories : [],
182183 permissions,
184+ // A person's token only: a workspace's acts as no one who signs in.
185+ website: !workspaceOwned && ["on", "true", "1"].includes(String(form.get("website") ?? "")),
183186 },
184187 };
185188 }
275278 if (scopesOfPermissions(input.permissions).join(" ") !== scopesOfPermissions(tokenPermissions(token)).join(" ")) {
276279 change.permissions = input.permissions;
277280 }
281+ if (!token.workspaceOwned && Boolean(input.website) !== Boolean(token.website)) change.website = Boolean(input.website);
278282 if (input.repositorySelection !== (token.repositorySelection ?? "all")) change.repositorySelection = input.repositorySelection;
279283 if (input.repositorySelection === "selected" && (change.repositorySelection || !sameNames(input.repositories, token.repositories))) {
280284 change.repositorySelection = "selected";
+14−0
111111 assert.equal(refused?.status, 429);
112112 });
113113
114+test("requests with an access token count by a hash of the token, at the API's limit", async () => {
115+ const env = { WEB_ADDRESS_LIMIT: binding(100), WEB_TOKEN_LIMIT: binding(1), WEB_SESSION_LIMIT: binding(100), WEB_ANONYMOUS_LIMIT: binding(0) };
116+ const withToken = () => request("/acme/rocket", { authorization: "Bearer g1t_secret", cookie: "g1t_session=abc123" });
117+ assert.equal(await pageLimited(env, withToken(), "/acme/rocket"), null);
118+ assert.equal(env.WEB_SESSION_LIMIT.keys.length, 0, "the token counts, not a cookie beside it");
119+ assert.equal(env.WEB_ANONYMOUS_LIMIT.keys.length, 0);
120+ assert.match(env.WEB_TOKEN_LIMIT.keys[0]!, /^token:[0-9a-f]{16}$/);
121+ assert.ok(!env.WEB_TOKEN_LIMIT.keys[0]!.includes("g1t_secret"));
122+ const refused = await pageLimited(env, withToken(), "/acme/rocket");
123+ assert.equal(refused?.status, 429);
124+ assert.match((await refused?.text()) ?? "", /this access token/);
125+ assert.equal(RATE_LIMITS.WEB_TOKEN_LIMIT.limit, RATE_LIMITS.API_TOKEN_LIMIT.limit);
126+});
127+
114128 test("files the Worker serves itself are never limited", async () => {
115129 const env = { WEB_ADDRESS_LIMIT: binding(0), WEB_ANONYMOUS_LIMIT: binding(0) };
116130 for (const path of ["/assets/app-1a2b.js", "/fonts/hanken.woff2", "/favicon.ico", "/robots.txt", "/llms.txt", "/sitemap.xml"]) {
+21−3
1010 * costly to answer (archives, run pages, logs, search). Signed in, by a
1111 * hash of the session cookie, higher: the session is not checked here,
1212 * which would cost a call to identity, and the ceiling per address keeps
13− * made-up cookies from getting round the signed-out limit.
13+ * made-up cookies from getting round the signed-out limit. With an
14+ * access token (`Authorization: Bearer`, app/lib/website-token.ts), by a
15+ * hash of the token, at the API's limit for a token; the address ceiling
16+ * applies as for cookies.
1417 *
1518 * Static assets never reach the Worker (the assets binding answers them),
1619 * and the few files it serves itself are left out here too. Every limit
2932 WEB_HEAVY_LIMIT?: RateLimitBinding;
3033 WEB_SESSION_LIMIT?: RateLimitBinding;
3134 WEB_ADDRESS_LIMIT?: RateLimitBinding;
35+ WEB_TOKEN_LIMIT?: RateLimitBinding;
3236 GIT_ANONYMOUS_LIMIT?: RateLimitBinding;
3337 GIT_SIGNED_LIMIT?: RateLimitBinding;
3438 };
5862 "Too many git requests from your network. Wait a minute and try again, or use credentials for a higher limit: https://docs.g1t.sh/reference/rate-limits/\n";
5963 const GIT_MESSAGE_SIGNED = "Too many git requests with these credentials. Wait a minute and try again: https://docs.g1t.sh/reference/rate-limits/\n";
6064 const PAGE_MESSAGE = "Too many requests from your network. Wait a minute and try again.\n";
65+const TOKEN_PAGE_MESSAGE = "Too many requests with this access token. Wait a minute and try again: https://docs.g1t.sh/reference/rate-limits/\n";
6166
6267 /**
6368 * The 429 for a git request past its limit, or null to go on. Git shows a
7378 return verdict === "limited" ? tooManyRequests(GIT_MESSAGE_ANONYMOUS) : null;
7479 }
7580
81+/**
82+ * The token in a page request's `Authorization: Bearer` header, or null
83+ * (app/lib/website-token.ts). Not checked here either.
84+ */
85+export function websiteToken(authorization: string | null): string | null {
86+ return /^\s*bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1] ?? null;
87+}
88+
7689 /** The 429 for a page or data request past its limit, or null to go on. */
7790 export async function pageLimited(env: FrontDoorLimits, request: Request, pathname: string): Promise<Response | null> {
7891 if (unlimited(pathname)) return null;
7992 const address = `ip:${clientAddress(request)}`;
93+ const token = websiteToken(request.headers.get("authorization"));
8094 const session = sessionCookie(request.headers.get("cookie"));
8195 const checks: Promise<string>[] = [checkLimit(env.WEB_ADDRESS_LIMIT, address)];
82− if (session) {
96+ if (token) {
97+ // A token on the website: per token, as the API counts it.
98+ checks.push(secretKey("token", token).then((key) => checkLimit(env.WEB_TOKEN_LIMIT, key)));
99+ } else if (session) {
83100 checks.push(secretKey("session", session).then((key) => checkLimit(env.WEB_SESSION_LIMIT, key)));
84101 } else {
85102 checks.push(checkLimit(env.WEB_ANONYMOUS_LIMIT, address));
87104 }
88105 const verdicts = await Promise.all(checks);
89106 if (!verdicts.includes("limited")) return null;
90− return tooManyRequests(session ? PAGE_MESSAGE : `${PAGE_MESSAGE.trimEnd()} Signed-in accounts have a higher limit.\n`);
107+ if (token && verdicts[1] === "limited") return tooManyRequests(TOKEN_PAGE_MESSAGE);
108+ return tooManyRequests(token || session ? PAGE_MESSAGE : `${PAGE_MESSAGE.trimEnd()} Signed-in accounts have a higher limit.\n`);
91109 }
92110
93111 /**
+1−1
116116 "readable ready_issues references registration repo_access resolve resolve_branch resolve_path resolve_slug " +
117117 "routes run run_context run_cost runner_groups runner_settings runners runs scorecards search search_memories " +
118118 "settings statement statement_entries status status_by_id suggest tree usage usage_meters user_by_username " +
119− "user_for_session usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
119+ "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
120120 "stars about public_links branch_drift tags last_commits languages contributors license releases release " +
121121 "stargazers starred commit_checks shortcuts"
122122 ).split(" "),
+12−0
1+/**
2+ * Whether a request came from another site: its `Origin` names an origin
3+ * other than the site's own. Form posts from g1t's pages carry the site's
4+ * origin; a request without the header (not from a browser's form) is not
5+ * cross-site. lib/session.server.ts's `assertSameOrigin` refuses these on
6+ * every action, for a session cookie and an access token alike
7+ * (lib/website-token.ts).
8+ */
9+export function crossOrigin(request: Request): boolean {
10+ const origin = request.headers.get("origin");
11+ return Boolean(origin && origin !== new URL(request.url).origin);
12+}
+28−9
1515 import { WORKSPACE_COOKIE, chosenWorkspace } from "./workspace-choice";
1616 import { codeGate } from "./workspace-nav";
1717 import { identity } from "./services.server";
18+import { TOKEN_CHALLENGE, bearerToken, tokenVerdict } from "./website-token";
19+import { crossOrigin } from "./same-origin";
1820
1921 const SESSION_COOKIE = "g1t_session";
2022 const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
2224 const viewerContext = createContext<Viewer>(null);
2325
2426 function sessionToken(request: Request): string | null {
27+ // A request with a token is the token's alone (lib/website-token.ts).
28+ if (bearerToken(request) !== null) return null;
2529 const cookies = request.headers.get("cookie") ?? "";
2630 const match = new RegExp(`(?:^|; )${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
2731 return match ? match[1] : null;
4044 * Everything on g1t lives in a workspace, so a confirmed account with none
4145 * is sent to create one, from wherever it was going, and returned there
4246 * afterwards.
47+ *
48+ * Automation can send an access token as `Authorization: Bearer` in place
49+ * of the cookie, when its owner let it use the website; the rules are in
50+ * lib/website-token.ts.
4351 */
44−export const viewerMiddleware: MiddlewareFunction<Response> = async ({
45− request,
46− context,
47−}) => {
48− const token = sessionToken(request);
49− if (!token) return;
50− const viewer = await identity.userForSession(token);
52+export const viewerMiddleware: MiddlewareFunction<Response> = async ({ request, context }, next) => {
53+ const verdict = await tokenVerdict(request, (token) => identity.userForAccessToken(token));
54+ // Thrown, so pages and client navigations show it as any error; the
55+ // Worker adds the 401's challenge header (workers/app.ts).
56+ if (verdict.kind === "refused") throw data(verdict.body, { status: verdict.status });
57+ if (verdict.kind === "signed-out") {
58+ // The page as anyone signed out sees it, saying the token was not taken.
59+ const response = await next();
60+ response.headers.set("www-authenticate", TOKEN_CHALLENGE);
61+ return response;
62+ }
63+ let viewer: Viewer;
64+ if (verdict.kind === "signed-in") {
65+ viewer = verdict.user;
66+ } else {
67+ const token = sessionToken(request);
68+ if (!token) return;
69+ viewer = await identity.userForSession(token);
70+ }
5171 context.set(viewerContext, viewer);
5272
5373 const { pathname, search } = new URL(request.url);
142162
143163 /** Rejects cross-site form posts; call at the top of every action. */
144164 export function assertSameOrigin(request: Request): void {
145− const origin = request.headers.get("origin");
146− if (origin && origin !== new URL(request.url).origin) {
165+ if (crossOrigin(request)) {
147166 throw new Response("Cross-origin request rejected", { status: 403 });
148167 }
149168 }
+171−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { User, Viewer } from "@g1t/contracts";
5+
6+import { crossOrigin } from "./same-origin.ts";
7+import {
8+ NEEDS_SIGN_IN,
9+ TOKEN_REFUSED,
10+ alwaysNeedsSignIn,
11+ bearerToken,
12+ isNeedsSignIn,
13+ needsRealSignIn,
14+ tokenVerdict,
15+ websiteUser,
16+} from "./website-token.ts";
17+
18+const ada: User = {
19+ id: "usr_ada",
20+ username: "ada",
21+ kind: "user",
22+ verified: true,
23+ workspaces: [{ slug: "acme", role: "owner" }],
24+ token: { token_id: "tok_web", scopes: ["repo:read"], website: true },
25+};
26+
27+/** identity's `user_for_access_token`, over a few tokens. */
28+function lookup(tokens: Record<string, Viewer>) {
29+ const asked: string[] = [];
30+ const resolve = async (token: string) => {
31+ asked.push(token);
32+ return tokens[token] ?? null;
33+ };
34+ return Object.assign(resolve, { asked });
35+}
36+
37+const tokens = lookup({
38+ g1t_web: ada,
39+ g1t_api_only: { ...ada, token: { token_id: "tok_api", scopes: null } },
40+ g1t_workspace: { ...ada, id: "wsp_1", username: "acme", kind: "workspace", token: { token_id: "tok_ws", website: true } },
41+ g1t_job: { ...ada, token: { token_id: "tok_job", website: true, job: { run_id: "run_1", job_id: "job_1" } } },
42+ g1t_agent: { ...ada, kind: "agent", token: { token_id: "tok_agent", website: true } },
43+});
44+
45+function request(path: string, init: { method?: string; headers?: Record<string, string>; body?: BodyInit } = {}): Request {
46+ return new Request(`https://g1t.sh${path}`, init);
47+}
48+
49+const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
50+
51+test("a token with the website permission signs the request in as its owner", async () => {
52+ for (const path of ["/", "/acme/rocket", "/acme/rocket/pull/1.data", "/settings/profile"]) {
53+ const verdict = await tokenVerdict(request(path, { headers: bearer("g1t_web") }), tokens);
54+ assert.equal(verdict.kind, "signed-in", path);
55+ assert.equal(verdict.kind === "signed-in" && verdict.user.username, "ada");
56+ }
57+ // A form post too, which a token's request needs no CSRF token for.
58+ const post = request("/acme/rocket/issues/new", { method: "POST", headers: { ...bearer("g1t_web"), origin: "https://g1t.sh" }, body: new URLSearchParams({ title: "x" }) });
59+ assert.equal((await tokenVerdict(post, tokens)).kind, "signed-in");
60+});
61+
62+test("a token without the website permission is no one: a page loads signed out, data and posts get a 401", async () => {
63+ for (const token of ["g1t_api_only", "g1t_workspace", "g1t_job", "g1t_agent"]) {
64+ assert.deepEqual(await tokenVerdict(request("/acme/rocket", { headers: bearer(token) }), tokens), { kind: "signed-out" }, token);
65+ assert.deepEqual(await tokenVerdict(request("/acme/rocket.data", { headers: bearer(token) }), tokens), { kind: "refused", status: 401, body: TOKEN_REFUSED }, token);
66+ const post = request("/acme/rocket/issues/new", { method: "POST", headers: bearer(token), body: new URLSearchParams({ title: "x" }) });
67+ assert.equal((await tokenVerdict(post, tokens)).kind, "refused", token);
68+ }
69+ assert.match(TOKEN_REFUSED, /Use the website as you/);
70+});
71+
72+test("a revoked, expired or made-up token is refused, and nothing else is tried", async () => {
73+ // identity answers null for a token deleted, expired or never made.
74+ assert.equal((await tokenVerdict(request("/_root.data", { headers: bearer("g1t_deleted") }), tokens)).kind, "refused");
75+ assert.equal((await tokenVerdict(request("/", { headers: bearer("g1t_deleted") }), tokens)).kind, "signed-out");
76+ // Not a g1t token at all: identity is not asked.
77+ const before = tokens.asked.length;
78+ assert.equal((await tokenVerdict(request("/x.data", { headers: bearer("not-a-token") }), tokens)).kind, "refused");
79+ assert.equal(tokens.asked.length, before);
80+});
81+
82+test("tokens are read from the Authorization header only, never a query string or a cookie", async () => {
83+ assert.deepEqual(await tokenVerdict(request("/?access_token=g1t_web&token=g1t_web"), tokens), { kind: "none" });
84+ assert.deepEqual(await tokenVerdict(request("/", { headers: { cookie: "g1t_session=g1t_web; token=g1t_web" } }), tokens), { kind: "none" });
85+ assert.equal(bearerToken(request("/", { headers: { authorization: "Basic " + btoa("ada:g1t_web") } })), null);
86+ assert.equal(bearerToken(request("/", { headers: { authorization: "bearer g1t_web " } })), "g1t_web");
87+ assert.equal(bearerToken(request("/", { headers: { authorization: "Bearer a b" } })), null);
88+});
89+
90+test("what needs a real sign-in is refused with a token, whatever the method", async () => {
91+ for (const path of [
92+ "/settings/tokens",
93+ "/settings/tokens/new",
94+ "/settings/tokens/tok_1.data",
95+ "/settings/two-factor",
96+ "/settings/emails",
97+ "/settings/keys",
98+ "/settings/account",
99+ "/settings/applications",
100+ "/settings/github",
101+ "/device",
102+ "/oauth/authorize",
103+ "/auth/github/callback",
104+ "/acme/-/tokens",
105+ "/acme/-/tokens/new.data",
106+ "/acme/-/personal-access-tokens",
107+ // As routes match them: any case, encoded, doubled or trailing slashes.
108+ "/Settings/Tokens",
109+ "/settings/%74okens",
110+ "//settings//two-factor/",
111+ ]) {
112+ assert.ok(alwaysNeedsSignIn(path), path);
113+ const verdict = await tokenVerdict(request(path, { headers: bearer("g1t_web") }), tokens);
114+ assert.deepEqual(verdict, { kind: "refused", status: 403, body: NEEDS_SIGN_IN }, path);
115+ }
116+ for (const path of ["/settings/profile", "/settings/notifications", "/settings/security-log", "/acme/-/settings", "/acme/-/billing", "/acme/rocket/settings"]) {
117+ assert.ok(!alwaysNeedsSignIn(path), path);
118+ }
119+ assert.ok(isNeedsSignIn(NEEDS_SIGN_IN));
120+ assert.ok(!isNeedsSignIn("Not found"));
121+});
122+
123+test("deleting or giving away a workspace and payment methods are refused; other changes there are not", async () => {
124+ const post = (path: string, fields: Record<string, string>) =>
125+ request(path, { method: "POST", headers: bearer("g1t_web"), body: new URLSearchParams(fields) });
126+ for (const [path, fields] of [
127+ ["/acme/-/settings.data", { intent: "delete" }],
128+ ["/acme/-/people", { action: "transfer", member: "bob" }],
129+ ["/acme/-/billing.data", { intent: "portal" }],
130+ ["/acme/-/billing", { intent: "card-check" }],
131+ ["/acme/-/billing", { intent: "subscribe" }],
132+ ["/acme/-/billing", { intent: "buy-ai-credit", amount: "10" }],
133+ ] as const) {
134+ assert.equal((await tokenVerdict(post(path, fields), tokens)).kind, "refused", `${path} ${JSON.stringify(fields)}`);
135+ }
136+ for (const [path, fields] of [
137+ ["/acme/-/settings", { intent: "rename", slug: "acme2" }],
138+ ["/acme/-/people", { action: "role", member: "bob", role: "member" }],
139+ ["/acme/-/billing", { intent: "budget" }],
140+ ] as const) {
141+ assert.equal((await tokenVerdict(post(path, fields), tokens)).kind, "signed-in", `${path} ${JSON.stringify(fields)}`);
142+ }
143+ // Looking at those pages is fine.
144+ assert.ok(!needsRealSignIn("/acme/-/billing", "GET", null));
145+ assert.ok(!needsRealSignIn("/acme/-/settings", "POST", null));
146+ // A multipart post is read too.
147+ const multipart = new FormData();
148+ multipart.set("intent", "delete");
149+ const deleting = request("/acme/-/settings", { method: "POST", headers: bearer("g1t_web"), body: multipart });
150+ assert.equal((await tokenVerdict(deleting, tokens)).kind, "refused");
151+ // The action still reads the same body afterwards.
152+ assert.equal((await deleting.formData()).get("intent"), "delete");
153+});
154+
155+test("only a person's own token with the permission is a website user", () => {
156+ assert.equal(websiteUser(ada)?.username, "ada");
157+ assert.equal(websiteUser(null), null);
158+ assert.equal(websiteUser({ ...ada, token: undefined }), null, "a session's user is not a token's");
159+ assert.equal(websiteUser({ ...ada, token: { token_id: "t", website: false } }), null);
160+ assert.equal(websiteUser({ ...ada, token: { token_id: "t", website: true, deploy_key: "key_1" } }), null);
161+ assert.equal(websiteUser({ ...ada, acting: { agent: "g1t" } as unknown as User["acting"] }), null);
162+});
163+
164+test("cross-site form posts are refused for a session cookie and a token alike", () => {
165+ const post = (headers: Record<string, string>) => request("/acme/rocket/issues/new", { method: "POST", headers });
166+ assert.ok(crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "https://evil.example" })));
167+ assert.ok(crossOrigin(post({ ...bearer("g1t_web"), origin: "https://evil.example" })));
168+ assert.ok(crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "null" })));
169+ assert.ok(!crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "https://g1t.sh" })));
170+ assert.ok(!crossOrigin(post(bearer("g1t_web"))), "automation that sends no Origin is not another site");
171+});
+183−0
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.
100+ { page: /^\/[^/]+\/-\/people$/, field: "action", values: ["transfer"] },
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+}
+6−0
7171 import { useSignUpCopy } from "./lib/registration";
7272 import { RELOADED_KEY, reloadFixes } from "./lib/stale-build";
7373 import { useNonce } from "./lib/nonce";
74+import { isNeedsSignIn } from "./lib/website-token";
7475 import { LiveNotifications } from "./components/notifications/live-notifications";
7576
7677
703704 if (typeof error.data === "string" && error.data) {
704705 details = error.data;
705706 }
707+ // A token asked for what needs a real sign-in (lib/website-token.ts).
708+ if (isNeedsSignIn(error.data)) {
709+ title = "This needs you to sign in";
710+ details = error.data.message;
711+ }
706712 } else if (import.meta.env.DEV && error instanceof Error) {
707713 details = error.message;
708714 stack = error.stack;
+8−1
1212 import { repositoryOfPage, stillPublic } from "../app/lib/public-cache";
1313 import { registryWorkspace, servicePath } from "../app/lib/registry-paths";
1414 import { usercontentPath } from "../app/lib/usercontent";
15+import { TOKEN_CHALLENGE } from "../app/lib/website-token";
1516 import { serveUsercontent } from "./usercontent";
1617
1718 const loadBuild = () => import("virtual:react-router/server-build");
103104 // and keeps the reader's D1 bookmarks (app/lib/perf.server.ts).
104105 const render = () => withRequestPerf(request, async () => finishResponse(request, await requestHandler(request)));
105106 if (anonymousPage(request, pathname)) return servePublic(env, request, ctx, render);
106− return render();
107+ const answer = await render();
108+ // A token the site refused (app/lib/website-token.ts) is told so as the
109+ // API tells it: React Router drops the headers of what middleware throws.
110+ if (answer.status === 401 && request.headers.has("authorization")) answer.headers.set("www-authenticate", TOKEN_CHALLENGE);
111+ return answer;
107112 }
108113
109114 /**
124129 function anonymousPage(request: Request, pathname: string): boolean {
125130 if (request.method !== "GET") return false;
126131 if (/(?:^|;\s*)g1t_session=/.test(request.headers.get("cookie") ?? "")) return false;
132+ // A token signs the request in (app/lib/website-token.ts): never kept, never served a kept page.
133+ if (request.headers.has("authorization")) return false;
127134 return PUBLIC_TOP.test(pathname) || PUBLIC_PROJECT.test(pathname);
128135 }
129136
+1−0
6464 WEB_HEAVY_LIMIT?: RateLimitBinding;
6565 WEB_SESSION_LIMIT?: RateLimitBinding;
6666 WEB_ADDRESS_LIMIT?: RateLimitBinding;
67+ WEB_TOKEN_LIMIT?: RateLimitBinding;
6768 GIT_ANONYMOUS_LIMIT?: RateLimitBinding;
6869 GIT_SIGNED_LIMIT?: RateLimitBinding;
6970 /**
+2−1
6565 { "name": "WEB_SESSION_LIMIT", "namespace_id": "4203", "simple": { "limit": 1200, "period": 60 } },
6666 { "name": "WEB_ADDRESS_LIMIT", "namespace_id": "4204", "simple": { "limit": 3000, "period": 60 } },
6767 { "name": "GIT_ANONYMOUS_LIMIT", "namespace_id": "4205", "simple": { "limit": 120, "period": 60 } },
68− { "name": "GIT_SIGNED_LIMIT", "namespace_id": "4206", "simple": { "limit": 1200, "period": 60 } }
68+ { "name": "GIT_SIGNED_LIMIT", "namespace_id": "4206", "simple": { "limit": 1200, "period": 60 } },
69+ { "name": "WEB_TOKEN_LIMIT", "namespace_id": "4207", "simple": { "limit": 1000, "period": 60 } }
6970 ],
7071 // Logs of a tenth of requests: every page view is one, too many to keep all.
7172 "observability": { "enabled": true, "head_sampling_rate": 0.1 },
+25−1
122122 .and_then(|acting| acting.run())
123123 .map(|run| run.kind.as_str().to_owned())
124124 .or_else(|| job.map(|_| WORKFLOW_JOB.to_owned())),
125+ // The token used, whatever the surface: a person's or a
126+ // workspace's on the API, MCP or git, and a person's on the
127+ // website (apps/web, app/lib/website-token.ts).
125128 credential_id: acting
126129 .map(|acting| acting.credential_id.clone())
127− .or_else(|| job.map(|(token, _)| token.token_id.clone())),
130+ .or_else(|| job.map(|(token, _)| token.token_id.clone()))
131+ .or_else(|| user.token.as_deref().map(|token| token.token_id.clone()).filter(|id| !id.is_empty())),
128132 }
129133 }
130134
342346 });
343347 assert_eq!(person.actor_kind, Some(ActorKind::Person));
344348 assert!(!person.records_reads());
349+ assert_eq!(person.credential_id, None, "signed in: no token");
350+ }
351+
352+ #[test]
353+ fn a_person_using_a_token_is_recorded_with_it_on_any_surface() {
354+ let user = User {
355+ id: "usr_1".to_owned(),
356+ username: "syntaqx".to_owned(),
357+ token: Some(Box::new(crate::scopes::TokenAccess {
358+ token_id: "tok_web".to_owned(),
359+ website: true,
360+ ..crate::scopes::TokenAccess::default()
361+ })),
362+ ..User::default()
363+ };
364+ let actor = AuditActor::of(&user);
365+ assert_eq!(actor.actor_kind, Some(ActorKind::Person));
366+ assert_eq!(actor.actor, "syntaqx");
367+ assert_eq!(actor.credential_id.as_deref(), Some("tok_web"));
368+ assert!(!actor.records_reads());
345369 }
346370
347371 #[test]
+4−0
7878 /// admin of the workspace's repositories rather than with Write.
7979 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
8080 pub admin: bool,
81+ /// A person's token its owner let use the website (g1t.sh) as them,
82+ /// with `Authorization: Bearer`. See [`crate::scopes::TokenAccess::website`].
83+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
84+ pub website: bool,
8185 }
8286
8387 /// `sign_in`: verifies a username, or any confirmed email address of the
+17−0
681681 /// `repo` is the one repository it reaches.
682682 #[serde(default, skip_serializing_if = "Option::is_none")]
683683 pub deploy_key: Option<String>,
684+ /// Set on a person's token whose owner let it use the website (g1t.sh)
685+ /// as them, sent as `Authorization: Bearer`. Not a scope: no preset,
686+ /// full access or OAuth grant includes it, and git, the API and MCP
687+ /// ignore it.
688+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
689+ pub website: bool,
684690 }
685691
686692 /// Which repositories a token reaches in the workspace it is made for.
17501756 }))
17511757 .unwrap();
17521758 assert_eq!(older, access);
1759+ // Using the website is off unless set, and said only when on.
1760+ assert!(!access.website);
1761+ assert!(wire_of(&access).get("website").is_none());
1762+ let website = TokenAccess { website: true, ..access };
1763+ assert_eq!(wire_of(&website)["website"], json!(true));
1764+ // Never part of full access.
1765+ assert!(!TokenAccess::full().website);
1766+ }
1767+
1768+ fn wire_of(access: &TokenAccess) -> serde_json::Value {
1769+ serde_json::to_value(access).unwrap()
17531770 }
17541771
17551772 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
+7−0
111111 /// out or `none` is no access. See [`crate::scopes::resolve_permissions`].
112112 #[serde(default)]
113113 pub permissions: BTreeMap<String, String>,
114+ /// Whether it may be used on the website as its owner: a person's token
115+ /// only. Never part of its permissions, so full access does not include it.
116+ #[serde(default)]
117+ pub website: bool,
114118 }
115119
116120 /// `update_token`: a person changes a token of theirs, or, as an owner,
137141 pub repositories: Option<Vec<String>>,
138142 #[serde(default)]
139143 pub permissions: Option<BTreeMap<String, String>>,
144+ /// Whether it may be used on the website; a person's token only.
145+ #[serde(default)]
146+ pub website: Option<bool>,
140147 }
141148
142149 /// A workspace's rules for personal access tokens.
+4−0
560560 # Signed in: your g1t_session cookie's value, from DevTools; never printed
561561 $env:G1T_SESSION = "<64 hex>"
562562 powershell -File scripts/perf/measure.ps1 -Runs 7 -Pull 12 -Issue 11 -Out before-signed-in.csv
563+# Or signed in with an access token that may use the website: the path of
564+# the file holding it (the token is never printed or put on a command line)
565+$env:G1T_TOKEN_FILE = "$HOME\.config\g1t\website-token"
566+powershell -File scripts/perf/measure.ps1 -Runs 7 -Out before-token.csv
563567 ```
564568
565569 It prints p50 and p90 of the server's share (TLS handshake done to first
+1−0
5858 | `WEB_ADDRESS_LIMIT` | 4204 | 3,000 | address | web: every request that reaches the Worker; stops made-up cookies getting round the signed-out limit |
5959 | `GIT_ANONYMOUS_LIMIT` | 4205 | 120 | address | web: git smart HTTP without `Authorization` (~40 clones) |
6060 | `GIT_SIGNED_LIMIT` | 4206 | 1,200 | `Authorization` hash | web: git smart HTTP with credentials, sandboxes' included |
61+| `WEB_TOKEN_LIMIT` | 4207 | 1,000 | token hash | web: pages and data requests with `Authorization: Bearer` (a token used on the website, not checked there); the same limit as `API_TOKEN_LIMIT`, counted apart |
6162 | `PACK_FILL_LIMIT` | 4301 | 30 | repository id | repos `src/limits.rs`: packs written to `GIT_PACKS`; past it the pack is streamed, not kept |
6263 | `ANONYMOUS_FETCH_LIMIT` | 4302 | 120 | repository id | repos: anonymous fetches the git store answers (cache hits never count) |
6364 | `API_ANONYMOUS_LIMIT` | 4401 | 60 | `rest:`/`mcp:` + address | api `src/limits.rs`: no token, or a wrong one |
+2−0
111111 repository_selection: input.repositorySelection,
112112 repositories: input.repositories,
113113 permissions: input.permissions,
114+ website: input.website ?? false,
114115 }),
115116 updateToken: (actor, id, change, owner) =>
116117 call("update_token", {
122123 repository_selection: change.repositorySelection ?? null,
123124 repositories: change.repositories ?? null,
124125 permissions: change.permissions ?? null,
126+ website: change.website ?? null,
125127 }),
126128 getTokenPolicy: (slug, viewer) => call("get_token_policy", { slug, viewer }),
127129 setTokenPolicy: (actor, slug, change) =>
+10−1
7979 repo?: string;
8080 /** Set on a workflow job's token (`G1T_TOKEN`): the run and job it was made for. */
8181 job?: { run_id: string; job_id: string; pull_requests?: boolean };
82+ /**
83+ * A person's token whose owner let it use the website as them, sent as
84+ * `Authorization: Bearer` (apps/web, lib/website-token.ts). Not a scope.
85+ */
86+ website?: boolean;
8287 };
8388 /**
8489 * Workspaces the person belongs to but cannot use until they meet its
591596 workspaceOwned?: boolean;
592597 /** A workspace's own token with Repositories: admin, an admin of its repositories. */
593598 admin?: boolean;
599+ /** A personal token its owner let use the website as them. */
600+ website?: boolean;
594601 };
595602
596603 /** Which repositories a token reaches in its workspace: all, the selected ones, or public ones only. */
617624 repositories: string[];
618625 /** Each resource's level; left out is no access. */
619626 permissions: Partial<Record<ScopeResource, ScopeLevel>>;
627+ /** A personal token: whether it may use the website as you. Off unless set. */
628+ website?: boolean;
620629 };
621630
622631 /** A change to a token; what is left out stays. */
623−export type TokenChange = Partial<Pick<TokenInput, "name" | "description" | "repositorySelection" | "repositories" | "permissions">>;
632+export type TokenChange = Partial<Pick<TokenInput, "name" | "description" | "repositorySelection" | "repositories" | "permissions" | "website">>;
624633
625634 /** A workspace's rules for personal access tokens. */
626635 export type TokenPolicy = {
+2−0
5656 WEB_ADDRESS_LIMIT: { worker: "apps/web", namespaceId: 4204, limit: 3000, per: "address, every request that reaches the Worker" },
5757 GIT_ANONYMOUS_LIMIT: { worker: "apps/web", namespaceId: 4205, limit: 120, per: "address, git requests without credentials" },
5858 GIT_SIGNED_LIMIT: { worker: "apps/web", namespaceId: 4206, limit: 1200, per: "credential, git requests with credentials" },
59+ // The same as API_TOKEN_LIMIT: a token counts alike on the website and the API.
60+ WEB_TOKEN_LIMIT: { worker: "apps/web", namespaceId: 4207, limit: 1000, per: "token, pages and data requests with an access token" },
5961 // services/repos (src/lib.rs): what an anonymous clone can cost a repository's owner.
6062 PACK_FILL_LIMIT: { worker: "services/repos", namespaceId: 4301, limit: 30, per: "repository, packs written to the pack cache" },
6163 ANONYMOUS_FETCH_LIMIT: { worker: "services/repos", namespaceId: 4302, limit: 120, per: "repository, anonymous fetches the store answers" },
+25−1
1616 powershell -File scripts/perf/measure.ps1 -Runs 7 -Out before.csv
1717
1818 .EXAMPLE
19+ # Signed in with an access token that has "Use the website as you" on:
20+ # G1T_TOKEN_FILE names the file holding it. The script never prints it.
21+ $env:G1T_TOKEN_FILE = "$HOME\.config\g1t\website-token"
22+ powershell -File scripts/perf/measure.ps1 -Runs 7
23+
24+.EXAMPLE
1925 # As a browser sees it: React Router streams to browsers and renders the
2026 # whole page first for anything it takes for a crawler, which curl's own
2127 # user agent is. Compare the two to see what streaming saves.
6066 $signedIn = [bool]$env:G1T_SESSION
6167 $cookieArgs = @()
6268 if ($signedIn) { $cookieArgs = @("-H", "Cookie: g1t_session=$($env:G1T_SESSION)") }
69+# Signed in with an access token that may use the website: G1T_TOKEN_FILE
70+# names the file holding it. curl reads the header from a temporary file,
71+# so the token is never on a command line or printed. It wins over
72+# G1T_SESSION, as on the site.
73+$tokenHeaderFile = $null
74+if ($env:G1T_TOKEN_FILE) {
75+ if (-not (Test-Path -LiteralPath $env:G1T_TOKEN_FILE -PathType Leaf)) { throw "G1T_TOKEN_FILE does not name a file." }
76+ $tokenHeaderFile = [System.IO.Path]::GetTempFileName()
77+ $header = "Authorization: Bearer " + (Get-Content -Raw -LiteralPath $env:G1T_TOKEN_FILE).Trim()
78+ [System.IO.File]::WriteAllText($tokenHeaderFile, $header)
79+ Remove-Variable header
80+ $cookieArgs = @("-H", "@$tokenHeaderFile")
81+ $signedIn = $true
82+}
6383 $agentArgs = @()
6484 if ($BrowserUA) {
6585 $agentArgs = @("-A", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36")
116136 return ($shown -join " ")
117137 }
118138
119−Write-Host "Measuring $Base, $Runs runs each, $(if ($signedIn) { 'signed in' } else { 'signed out' }), $(if ($BrowserUA) { 'as a browser (streamed)' } else { 'as curl (a crawler: whole page first)' })."
139+Write-Host "Measuring $Base, $Runs runs each, $(if ($tokenHeaderFile) { 'signed in with a token' } elseif ($signedIn) { 'signed in' } else { 'signed out' }), $(if ($BrowserUA) { 'as a browser (streamed)' } else { 'as curl (a crawler: whole page first)' })."
140+try {
120141 $rows = foreach ($path in $Paths) {
121142 $url = "$Base$path"
122143 $null = Measure-Once $url # warm the connection and the isolate
135156 "Server-Timing (last run)" = Summarize-Timing $last.Timing
136157 }
137158 }
159+} finally {
160+ if ($tokenHeaderFile) { Remove-Item -LiteralPath $tokenHeaderFile -ErrorAction SilentlyContinue }
161+}
138162
139163 $rows | Format-Table -AutoSize -Wrap
140164 if ($Out) {
+7−0
1+-- A person's access token may be let use the website (g1t.sh) as them,
2+-- sent as `Authorization: Bearer`, so automation driving a browser can
3+-- work there without signing in. Off unless its owner turns it on when
4+-- making or changing the token. It is not a scope: full access and every
5+-- existing token stay off. See src/tokens.rs and apps/web's
6+-- app/lib/website-token.ts.
7+ALTER TABLE access_tokens ADD COLUMN website INTEGER NOT NULL DEFAULT 0;
+26−2
5151
5252 const DAY_SECONDS: u64 = 86_400;
5353
54+/// Why a workspace's token cannot use the website: it acts as no person.
55+const WORKSPACE_TOKEN_NO_WEBSITE: &str = "Only a person's token can use the website: a workspace's token acts as no one who can sign in.";
56+
5457 /// What a token row says about its reach, read with it when it is used.
5558 /// Numbers arrive from D1 as floats.
5659 #[derive(Clone, Debug, Default, Deserialize)]
107110 review_reason: Option<String>,
108111 #[serde(default)]
109112 owner_workspace: Option<String>,
113+ /// 1 when its owner let it use the website (migration 0043).
114+ #[serde(default)]
115+ website: Option<f64>,
110116 }
111117
112118 impl TokenRowMore {
120126 info.repository_selection = self.repository_selection.as_deref().and_then(RepositorySelection::parse).unwrap_or_default();
121127 info.status = TokenStatus::parse(self.status.as_deref().unwrap_or("active"));
122128 info.review_reason = self.review_reason.clone();
129+ info.website = self.website.is_some_and(|on| on >= 1.0) && self.workspace_id.is_none();
123130 }
124131 }
125132
431438 Outcome::Ok(id) => id,
432439 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
433440 };
441+ if a.website {
442+ return Ok(Outcome::fail(FailureCode::Invalid, WORKSPACE_TOKEN_NO_WEBSITE));
443+ }
434444 let scopes = match resolve_permissions(&a.permissions, false) {
435445 Ok(scopes) => scopes,
436446 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
532542 let description = tidy(a.description.as_deref());
533543 let mut statements = vec![self
534544 .db
535− .prepare("UPDATE access_tokens SET owner_workspace_id = ?, repository_selection = ?, description = ?, status = ? WHERE id = ?")
545+ .prepare("UPDATE access_tokens SET owner_workspace_id = ?, repository_selection = ?, description = ?, status = ?, website = ? WHERE id = ?")
536546 .bind(&[
537547 text(workspace_id.as_deref()),
538548 selection.as_str().into(),
539549 text(description.as_deref()),
540550 status.as_str().into(),
551+ JsValue::from(u8::from(a.website)),
541552 created.info.id.as_str().into(),
542553 ])?];
543554 statements.extend(self.repository_rows(&created.info.id, &repo_ids)?);
547558 created.info.repository_selection = selection;
548559 created.info.repositories = qualified_all(slug.as_deref(), &repo_ids, &a.repositories);
549560 created.info.status = status;
561+ created.info.website = a.website;
550562 // In the person's security log and their workspaces' audit logs.
551563 self.log_security(&a.actor.id, "token_created", Some(&created.info.name), None).await;
552− self.audit_account(&a.actor, "token.created", &format!("Created access token {}", created.info.name)).await;
564+ let website = if a.website { " that can use the website" } else { "" };
565+ self.audit_account(&a.actor, "token.created", &format!("Created access token {}{website}", created.info.name)).await;
553566 if let (Some(slug), TokenStatus::Pending) = (&slug, status) {
554567 self.ask_owners(&a.actor, slug, &created.info).await?;
555568 }
618631 sets.push(("scopes", scopes_text(&scopes).into()));
619632 widened = true;
620633 }
634+ // Using the website: a person's token only. Turning it on is
635+ // asking for more; turning it off is not.
636+ if let Some(website) = a.website {
637+ if website && !personal {
638+ return Ok(Outcome::fail(FailureCode::Invalid, WORKSPACE_TOKEN_NO_WEBSITE));
639+ }
640+ if personal {
641+ sets.push(("website", JsValue::from(u8::from(website))));
642+ widened |= website;
643+ }
644+ }
621645 if let Some(selection) = a.repository_selection {
622646 if selection == RepositorySelection::Selected && repo_workspace.is_none() {
623647 return Ok(Outcome::fail(FailureCode::Invalid, "This token is not made for one workspace, so it cannot select repositories."));
+36−3
2929 access_tokens.last_used_at, users.username AS created_by, access_tokens.scopes,
3030 access_tokens.expires_at, access_tokens.description, access_tokens.admin,
3131 access_tokens.workspace_id, access_tokens.repository_selection,
32− access_tokens.status, access_tokens.review_reason,
32+ access_tokens.status, access_tokens.review_reason, access_tokens.website,
3333 (SELECT slug FROM workspaces WHERE workspaces.id = access_tokens.owner_workspace_id) AS owner_workspace";
3434
3535 /// Who a new token belongs to.
121121 job_run_id: Option<String>,
122122 #[serde(default)]
123123 job_pulls: Option<u32>,
124+ /// 1 when its owner let it use the website (migration 0043).
125+ #[serde(default)]
126+ website: Option<f64>,
124127 /// The workspace it is made for, its repositories and status, a
125128 /// workspace token's Admin, and when it was made and expires, for the
126129 /// rules of the workspaces it reaches (token_reach.rs).
138141 value.map_or(JsValue::NULL, JsValue::from)
139142 }
140143
144+/// Whether a token being used may be used on the website as its owner: one
145+/// a person made and turned that on for, never a workspace's, a job's or an
146+/// agent's (apps/web, app/lib/website-token.ts).
147+fn website_allowed(presented: &Presented) -> bool {
148+ presented.website.is_some_and(|on| on >= 1.0)
149+ && presented.user_id.is_some()
150+ && presented.workspace_id.is_none()
151+ && presented.job_id.is_none()
152+ && presented.agent_scope.is_none()
153+}
154+
141155 impl Identity {
142156 pub async fn user_for_access_token(&self, token: &str) -> Result<Viewer> {
143157 if !token.starts_with(TOKEN_PREFIX) {
148162 .prepare(format!(
149163 "SELECT id, user_id, workspace_id, last_used_at, agent_scope, scopes, name,
150164 repo, job_id, job_run_id, job_pulls, created_at, expires_at,
151− owner_workspace_id, repository_selection, status, admin
165+ owner_workspace_id, repository_selection, status, admin, website
152166 FROM access_tokens
153167 WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > {SQL_NOW})"
154168 ))
178192 let mut viewer = match (&presented.user_id, &presented.workspace_id) {
179193 (Some(user_id), _) => {
180194 self.find_user(
181− "SELECT id, username, email_verified_at IS NOT NULL AS verified
195+ "SELECT id, username, display_username, email_verified_at IS NOT NULL AS verified, avatar
182196 FROM users WHERE id = ? AND deleted_at IS NULL",
183197 user_id,
184198 )
204218 }),
205219 _ => None,
206220 },
221+ website: website_allowed(&presented),
207222 ..TokenAccess::default()
208223 }));
209224 // What it reaches: the workspace it is made for and its
542557 assert_eq!(Grant::asked(&None).scopes_column(), "*");
543558 assert_eq!(Grant::asked(&Some(vec![])).scopes_column(), "");
544559 }
560+
561+ #[test]
562+ fn only_a_persons_own_token_with_it_turned_on_uses_the_website() {
563+ let row = |extra: serde_json::Value| {
564+ let mut value = serde_json::json!({ "id": "tok_1", "user_id": "usr_1", "workspace_id": null, "last_used_at": null, "agent_scope": null, "scopes": "*", "website": 1.0 });
565+ for (key, field) in extra.as_object().unwrap() {
566+ value[key] = field.clone();
567+ }
568+ serde_json::from_value::<Presented>(value).unwrap()
569+ };
570+ assert!(website_allowed(&row(serde_json::json!({}))));
571+ assert!(!website_allowed(&row(serde_json::json!({ "website": 0.0 }))));
572+ assert!(!website_allowed(&row(serde_json::json!({ "website": null }))));
573+ // A workspace's token, a job's token and an agent's never do.
574+ assert!(!website_allowed(&row(serde_json::json!({ "user_id": null, "workspace_id": "wsp_1" }))));
575+ assert!(!website_allowed(&row(serde_json::json!({ "job_id": "job_1" }))));
576+ assert!(!website_allowed(&row(serde_json::json!({ "agent_scope": "{}" }))));
577+ }
545578 }