Skip to content
275 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge main into Artifacts Phase 21import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { User, Viewer } from "@g1t/contracts";
5
6import { crossOrigin } from "./same-origin.ts";
7import {
8 NEEDS_SIGN_IN,
9 TOKEN_REFUSED,
10 alwaysNeedsSignIn,
11 bearerToken,
12 isNeedsSignIn,
13 needsRealSignIn,
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)14 scopedRoute,
15 scopedUser,
16 socketUser,
Merge main into Artifacts Phase 217 tokenVerdict,
18 websiteUser,
19} from "./website-token.ts";
20
21const ada: User = {
22 id: "usr_ada",
23 username: "ada",
24 kind: "user",
25 verified: true,
26 workspaces: [{ slug: "acme", role: "owner" }],
27 token: { token_id: "tok_web", scopes: ["repo:read"], website: true },
28};
29
30/** identity's `user_for_access_token`, over a few tokens. */
31function lookup(tokens: Record<string, Viewer>) {
32 const asked: string[] = [];
33 const resolve = async (token: string) => {
34 asked.push(token);
35 return tokens[token] ?? null;
36 };
37 return Object.assign(resolve, { asked });
38}
39
40const tokens = lookup({
41 g1t_web: ada,
42 g1t_api_only: { ...ada, token: { token_id: "tok_api", scopes: null } },
43 g1t_workspace: { ...ada, id: "wsp_1", username: "acme", kind: "workspace", token: { token_id: "tok_ws", website: true } },
44 g1t_job: { ...ada, token: { token_id: "tok_job", website: true, job: { run_id: "run_1", job_id: "job_1" } } },
45 g1t_agent: { ...ada, kind: "agent", token: { token_id: "tok_agent", website: true } },
46});
47
48function request(path: string, init: { method?: string; headers?: Record<string, string>; body?: BodyInit } = {}): Request {
49 return new Request(`https://g1t.sh${path}`, init);
50}
51
52const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
53
54test("a token with the website permission signs the request in as its owner", async () => {
55 for (const path of ["/", "/acme/rocket", "/acme/rocket/pull/1.data", "/settings/profile"]) {
56 const verdict = await tokenVerdict(request(path, { headers: bearer("g1t_web") }), tokens);
57 assert.equal(verdict.kind, "signed-in", path);
58 assert.equal(verdict.kind === "signed-in" && verdict.user.username, "ada");
59 }
60 // A form post too, which a token's request needs no CSRF token for.
61 const post = request("/acme/rocket/issues/new", { method: "POST", headers: { ...bearer("g1t_web"), origin: "https://g1t.sh" }, body: new URLSearchParams({ title: "x" }) });
62 assert.equal((await tokenVerdict(post, tokens)).kind, "signed-in");
63});
64
65test("a token without the website permission is no one: a page loads signed out, data and posts get a 401", async () => {
66 for (const token of ["g1t_api_only", "g1t_workspace", "g1t_job", "g1t_agent"]) {
67 assert.deepEqual(await tokenVerdict(request("/acme/rocket", { headers: bearer(token) }), tokens), { kind: "signed-out" }, token);
68 assert.deepEqual(await tokenVerdict(request("/acme/rocket.data", { headers: bearer(token) }), tokens), { kind: "refused", status: 401, body: TOKEN_REFUSED }, token);
69 const post = request("/acme/rocket/issues/new", { method: "POST", headers: bearer(token), body: new URLSearchParams({ title: "x" }) });
70 assert.equal((await tokenVerdict(post, tokens)).kind, "refused", token);
71 }
72 assert.match(TOKEN_REFUSED, /Use the website as you/);
73});
74
75test("a revoked, expired or made-up token is refused, and nothing else is tried", async () => {
76 // identity answers null for a token deleted, expired or never made.
77 assert.equal((await tokenVerdict(request("/_root.data", { headers: bearer("g1t_deleted") }), tokens)).kind, "refused");
78 assert.equal((await tokenVerdict(request("/", { headers: bearer("g1t_deleted") }), tokens)).kind, "signed-out");
79 // Not a g1t token at all: identity is not asked.
80 const before = tokens.asked.length;
81 assert.equal((await tokenVerdict(request("/x.data", { headers: bearer("not-a-token") }), tokens)).kind, "refused");
82 assert.equal(tokens.asked.length, before);
83});
84
85test("tokens are read from the Authorization header only, never a query string or a cookie", async () => {
86 assert.deepEqual(await tokenVerdict(request("/?access_token=g1t_web&token=g1t_web"), tokens), { kind: "none" });
87 assert.deepEqual(await tokenVerdict(request("/", { headers: { cookie: "g1t_session=g1t_web; token=g1t_web" } }), tokens), { kind: "none" });
88 assert.equal(bearerToken(request("/", { headers: { authorization: "Basic " + btoa("ada:g1t_web") } })), null);
89 assert.equal(bearerToken(request("/", { headers: { authorization: "bearer g1t_web " } })), "g1t_web");
90 assert.equal(bearerToken(request("/", { headers: { authorization: "Bearer a b" } })), null);
91});
92
93test("what needs a real sign-in is refused with a token, whatever the method", async () => {
94 for (const path of [
95 "/settings/tokens",
96 "/settings/tokens/new",
97 "/settings/tokens/tok_1.data",
98 "/settings/two-factor",
99 "/settings/emails",
100 "/settings/keys",
101 "/settings/account",
102 "/settings/applications",
103 "/settings/github",
104 "/device",
105 "/oauth/authorize",
106 "/auth/github/callback",
107 "/acme/-/tokens",
108 "/acme/-/tokens/new.data",
109 "/acme/-/personal-access-tokens",
110 // As routes match them: any case, encoded, doubled or trailing slashes.
111 "/Settings/Tokens",
112 "/settings/%74okens",
113 "//settings//two-factor/",
114 ]) {
115 assert.ok(alwaysNeedsSignIn(path), path);
116 const verdict = await tokenVerdict(request(path, { headers: bearer("g1t_web") }), tokens);
117 assert.deepEqual(verdict, { kind: "refused", status: 403, body: NEEDS_SIGN_IN }, path);
118 }
119 for (const path of ["/settings/profile", "/settings/notifications", "/settings/security-log", "/acme/-/settings", "/acme/-/billing", "/acme/rocket/settings"]) {
120 assert.ok(!alwaysNeedsSignIn(path), path);
121 }
122 assert.ok(isNeedsSignIn(NEEDS_SIGN_IN));
123 assert.ok(!isNeedsSignIn("Not found"));
124});
125
126test("deleting or giving away a workspace and payment methods are refused; other changes there are not", async () => {
127 const post = (path: string, fields: Record<string, string>) =>
128 request(path, { method: "POST", headers: bearer("g1t_web"), body: new URLSearchParams(fields) });
129 for (const [path, fields] of [
130 ["/acme/-/settings.data", { intent: "delete" }],
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.131 ["/acme/-/members", { action: "transfer", member: "bob" }],
Merge main into Artifacts Phase 2132 ["/acme/-/billing.data", { intent: "portal" }],
133 ["/acme/-/billing", { intent: "card-check" }],
134 ["/acme/-/billing", { intent: "subscribe" }],
135 ["/acme/-/billing", { intent: "buy-ai-credit", amount: "10" }],
136 ] as const) {
137 assert.equal((await tokenVerdict(post(path, fields), tokens)).kind, "refused", `${path} ${JSON.stringify(fields)}`);
138 }
139 for (const [path, fields] of [
140 ["/acme/-/settings", { intent: "rename", slug: "acme2" }],
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.141 ["/acme/-/members", { action: "role", member: "bob", role: "member" }],
Merge main into Artifacts Phase 2142 ["/acme/-/billing", { intent: "budget" }],
143 ] as const) {
144 assert.equal((await tokenVerdict(post(path, fields), tokens)).kind, "signed-in", `${path} ${JSON.stringify(fields)}`);
145 }
146 // Looking at those pages is fine.
147 assert.ok(!needsRealSignIn("/acme/-/billing", "GET", null));
148 assert.ok(!needsRealSignIn("/acme/-/settings", "POST", null));
149 // A multipart post is read too.
150 const multipart = new FormData();
151 multipart.set("intent", "delete");
152 const deleting = request("/acme/-/settings", { method: "POST", headers: bearer("g1t_web"), body: multipart });
153 assert.equal((await tokenVerdict(deleting, tokens)).kind, "refused");
154 // The action still reads the same body afterwards.
155 assert.equal((await deleting.formData()).get("intent"), "delete");
156});
157
158test("only a person's own token with the permission is a website user", () => {
159 assert.equal(websiteUser(ada)?.username, "ada");
160 assert.equal(websiteUser(null), null);
161 assert.equal(websiteUser({ ...ada, token: undefined }), null, "a session's user is not a token's");
162 assert.equal(websiteUser({ ...ada, token: { token_id: "t", website: false } }), null);
163 assert.equal(websiteUser({ ...ada, token: { token_id: "t", website: true, deploy_key: "key_1" } }), null);
164 assert.equal(websiteUser({ ...ada, acting: { agent: "g1t" } as unknown as User["acting"] }), null);
165});
166
167test("cross-site form posts are refused for a session cookie and a token alike", () => {
168 const post = (headers: Record<string, string>) => request("/acme/rocket/issues/new", { method: "POST", headers });
169 assert.ok(crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "https://evil.example" })));
170 assert.ok(crossOrigin(post({ ...bearer("g1t_web"), origin: "https://evil.example" })));
171 assert.ok(crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "null" })));
172 assert.ok(!crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "https://g1t.sh" })));
173 assert.ok(!crossOrigin(post(bearer("g1t_web"))), "automation that sends no Origin is not another site");
174});
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)175
176/** A person's token without the website permission, as an OAuth sign-in (the phone app) or a personal token has it. */
177const scoped = (scopes: string[] | null, more: Partial<NonNullable<User["token"]>> = {}): User => ({
178 ...ada,
179 token: { token_id: "tok_app", scopes, ...more },
180});
181
182const scopedTokens = lookup({
183 g1t_chat_read: scoped(["chat:read", "notifications:read"]),
184 g1t_chat_write: scoped(["chat:write", "notifications:write"]),
185 g1t_no_chat: scoped(["repo:read", "issues:write", "notifications:read"]),
186 g1t_full: scoped(null),
187 g1t_legacy: scoped(null, { legacy: true }),
188 g1t_web: ada,
189 g1t_workspace: { ...ada, id: "wsp_1", username: "acme", kind: "workspace", token: { token_id: "tok_ws", scopes: ["chat:write"] } },
190 g1t_job: scoped(["chat:write"], { job: { run_id: "run_1", job_id: "job_1" } }),
191 g1t_deploy: scoped(["chat:write"], { deploy_key: "key_1" }),
192 g1t_agent: { ...scoped(["chat:write"]), kind: "agent" },
193});
194
195const verdictOf = async (token: string, path: string, method = "GET") =>
196 (await tokenVerdict(request(path, { method, headers: bearer(token), body: method === "GET" ? undefined : JSON.stringify({ intent: "post" }) }), scopedTokens)).kind;
197
198test("a token with chat:read reads Chat's JSON without the website permission, and sends nothing", async () => {
199 for (const path of ["/acme/-/chat/api", "/acme/-/chat/api?channel=ch_1&thread=m_1", "/Acme//-/chat/api/", "/acme/-/chat/person/bob"]) {
200 assert.equal(await verdictOf("g1t_chat_read", path), "signed-in", path);
201 }
202 assert.equal(await verdictOf("g1t_chat_read", "/acme/-/chat/api", "POST"), "refused");
203 assert.equal(await verdictOf("g1t_chat_read", "/acme/-/chat/person/bob", "POST"), "refused");
204});
205
206test("a token with chat:write sends to Chat's JSON, and reads it too", async () => {
207 assert.equal(await verdictOf("g1t_chat_write", "/acme/-/chat/api", "POST"), "signed-in");
208 assert.equal(await verdictOf("g1t_chat_write", "/acme/-/chat/api"), "signed-in");
209 assert.equal(await verdictOf("g1t_chat_write", "/-/live/ticket?path=%2Facme%2F-%2Fchat%2Flive"), "signed-in");
210});
211
212test("a token without a chat scope, or not a person's own, reaches none of Chat", async () => {
213 for (const token of ["g1t_no_chat", "g1t_workspace", "g1t_job", "g1t_deploy", "g1t_agent"]) {
214 assert.equal(await verdictOf(token, "/acme/-/chat/api"), "signed-out", token);
215 assert.equal(await verdictOf(token, "/acme/-/chat/api", "POST"), "refused", token);
216 assert.equal(await verdictOf(token, "/-/live/ticket?path=%2F-%2Flive"), "signed-out", token);
217 }
218});
219
220test("a chat-scoped token opens nothing else on the site: pages are signed out, data and posts are a 401", async () => {
221 for (const token of ["g1t_chat_write", "g1t_full"]) {
222 for (const path of ["/", "/acme", "/acme/-/chat", "/acme/-/chat/general", "/acme/rocket", "/settings/profile", "/acme/-/chat/live", "/-/live"]) {
223 assert.deepEqual(await tokenVerdict(request(path, { headers: bearer(token) }), scopedTokens), { kind: "signed-out" }, `${token} ${path}`);
224 }
225 // A client navigation's data runs the loaders around the route as well, so it is not Chat's JSON.
226 for (const path of ["/acme/-/chat/api.data", "/acme/-/chat/person/bob.data", "/acme/-/chat.data", "/-/notify.data", "/-/live/ticket.data", "/_root.data"]) {
227 assert.deepEqual(await tokenVerdict(request(path, { headers: bearer(token) }), scopedTokens), { kind: "refused", status: 401, body: TOKEN_REFUSED }, `${token} ${path}`);
228 }
229 for (const path of ["/acme/rocket/issues/new", "/acme/-/chat/general", "/-/live/ticket", "/acme/-/chat/person/bob", "/-/-/chat/api"]) {
230 assert.equal(await verdictOf(token, path, "POST"), "refused", `${token} ${path}`);
231 }
232 assert.equal(await verdictOf(token, "/acme/-/chat/api", "DELETE"), "refused");
233 }
234 assert.equal(scopedRoute("/-/-/chat/api", "GET"), null, "`-` is no workspace");
235 assert.equal(scopedRoute("/acme/web/-/chat/api", "GET"), null);
236 assert.equal(scopedRoute("/acme/-/chat/person/bob/x", "GET"), null);
237});
238
239test("push registration and notification settings take the notifications scopes", async () => {
240 assert.equal(await verdictOf("g1t_chat_read", "/-/notify"), "signed-in");
241 assert.equal(await verdictOf("g1t_chat_read", "/-/notify", "POST"), "refused");
242 assert.equal(await verdictOf("g1t_chat_write", "/-/notify", "POST"), "signed-in");
243 assert.equal(await verdictOf("g1t_chat_write", "/-/notify"), "signed-in", "write includes read");
244 assert.equal(await verdictOf("g1t_no_chat", "/-/notify"), "signed-in", "notifications:read alone is enough");
245 assert.equal(await verdictOf("g1t_no_chat", "/-/notify", "POST"), "refused");
246 assert.equal(scopedRoute("/-/notify", "POST"), "notifications:write");
247});
248
249test("full access, legacy or not, reaches Chat only with the website permission; the website permission keeps opening everything", async () => {
250 for (const token of ["g1t_full", "g1t_legacy"]) {
251 assert.equal(await verdictOf(token, "/acme/-/chat/api", "GET"), "signed-out", token);
252 assert.equal(await verdictOf(token, "/acme/-/chat/api", "POST"), "refused", token);
253 assert.equal(await verdictOf(token, "/-/notify", "POST"), "refused", token);
254 assert.equal(socketUser(scoped(null), "/-/live"), null, token);
255 }
256 // A website token with no chat scope is the website's as before, Chat included.
257 assert.equal(await verdictOf("g1t_web", "/acme/-/chat/api", "POST"), "signed-in");
258 assert.equal(await verdictOf("g1t_web", "/acme/rocket"), "signed-in");
259 assert.equal(scopedUser(scoped(["chat:read"]), "/acme/-/chat/api", "GET")?.username, "ada");
260 assert.equal(scopedUser({ ...ada, token: undefined }, "/acme/-/chat/api", "GET"), null, "a session is not a token");
261 assert.equal(scopedUser(scoped(["chat:bogus", "chat"]), "/acme/-/chat/api", "GET"), null);
262});
263
264test("a chat-scoped token opens Chat's socket and the feed, never an artifact's", () => {
265 const reader = scoped(["chat:read"]);
266 assert.equal(socketUser(reader, "/acme/-/chat/live")?.username, "ada");
267 assert.equal(socketUser(reader, "/-/live")?.username, "ada");
268 assert.equal(socketUser(reader, "/acme/-/artifacts/live"), null);
269 assert.equal(socketUser(scoped(null), "/acme/-/chat/live"), null, "full access, without the website permission");
270 assert.equal(socketUser(scoped(["notifications:write"]), "/-/live"), null);
271 assert.equal(socketUser(scoped(["chat:read"], { job: { run_id: "run_1", job_id: "job_1" } }), "/-/live"), null);
272 assert.equal(socketUser({ ...ada, token: undefined }, "/-/live"), null);
273 // The website permission opens every socket, as before.
274 assert.equal(socketUser(ada, "/acme/-/artifacts/live")?.username, "ada");
275});

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