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, | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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 | }); | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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.