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 | 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, | |
| 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 2 | 17 | tokenVerdict, |
| 18 | websiteUser, | |
| 19 | } from "./website-token.ts"; | |
| 20 | ||
| 21 | const 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. */ | |
| 31 | function 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 | ||
| 40 | const 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 | ||
| 48 | function request(path: string, init: { method?: string; headers?: Record<string, string>; body?: BodyInit } = {}): Request { | |
| 49 | return new Request(`https://g1t.sh${path}`, init); | |
| 50 | } | |
| 51 | ||
| 52 | const bearer = (token: string) => ({ authorization: `Bearer ${token}` }); | |
| 53 | ||
| 54 | test("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 | ||
| 65 | test("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 | ||
| 75 | test("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 | ||
| 85 | test("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 | ||
| 93 | test("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 | ||
| 126 | test("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 2 | 132 | ["/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 2 | 142 | ["/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 | ||
| 158 | test("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 | ||
| 167 | test("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. */ | |
| 177 | const scoped = (scopes: string[] | null, more: Partial<NonNullable<User["token"]>> = {}): User => ({ | |
| 178 | ...ada, | |
| 179 | token: { token_id: "tok_app", scopes, ...more }, | |
| 180 | }); | |
| 181 | ||
| 182 | const 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 | ||
| 195 | const 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 | ||
| 198 | test("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 | ||
| 206 | test("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 | ||
| 212 | test("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 | ||
| 220 | test("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 | ||
| 239 | test("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 | ||
| 249 | test("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 | ||
| 264 | test("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.