Skip to content
164 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.

A page opened with an access token keeps its live sockets connected: just before it opens the feed, a conversation or an artifact's room, it asks GET /-/live/ticket with the token for a socket ticket and adds it to the socket's address, because a browser cannot put the Authorization header on a WebSocket. A ticket seals the token and its owner with a key derived from USERCONTENT_KEY, lasts 60 seconds, opens only the socket path it was made for, is read only by a WebSocket upgrade and never by a page, data request, form post or the API, and the token is checked again when the socket opens, so one deleted, expired, revoked or without Use the website as you opens nothing. Sessions open their sockets as before, with no ticket, and the authentication guide and the rate limits notes say how it works.1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { User, Viewer } from "@g1t/contracts";
5
6import { liveAddress, openLive, setLiveViaToken } from "./live-socket.ts";
7import { TICKET_PARAM, TICKET_ROUTE, TICKET_SECONDS, issueTicket, openTicket, socketPath, ticketViewer } from "./socket-ticket.ts";
8import { tokenVerdict, websiteUser } from "./website-token.ts";
9
10const SECRET = "site-secret";
11const NOW = Date.UTC(2026, 9, 9, 12, 0, 0);
12
13const ada: User = {
14 id: "usr_ada",
15 username: "ada",
16 kind: "user",
17 verified: true,
18 workspaces: [{ slug: "acme", role: "owner" }],
19 token: { token_id: "tok_web", scopes: ["repo:read"], website: true },
20};
21
22/** identity's `user_for_access_token` as the site narrows it, over a table that tests change. */
23const table: Record<string, Viewer> = {};
24const lookup = async (token: string) => websiteUser(table[token] ?? null);
25
26function reset() {
27 for (const key of Object.keys(table)) delete table[key];
28 table.g1t_web = ada;
29}
30
31function upgrade(path: string, ticket: string | null, headers: Record<string, string> = { upgrade: "websocket" }): Request {
32 const url = new URL(`https://g1t.sh${path}`);
33 if (ticket) url.searchParams.set("ticket", ticket);
34 return new Request(url, { headers });
35}
36
37const CHAT = "/acme/-/chat/live";
38
39test("only the site's live sockets take a ticket, in the form the routes match", () => {
40 assert.deepEqual(socketPath("/-/live"), { path: "/-/live", workspace: null });
41 assert.deepEqual(socketPath("/Acme//-/chat/live/"), { path: "/acme/-/chat/live", workspace: "acme" });
42 assert.deepEqual(socketPath("/acme/-/artifacts/live"), { path: "/acme/-/artifacts/live", workspace: "acme" });
43 for (const path of ["/", "/acme", "/acme/-/chat", "/acme/-/inbox/live", "/-/-/chat/live", "/acme/web/-/chat/live", "/-/live/ticket", "/%E0%A4%A"]) {
44 assert.equal(socketPath(path), null, path);
45 }
46});
47
48test("a ticket opens the socket it was made for, as the token's owner", async () => {
49 reset();
50 const { ticket, expires_at } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
51 assert.match(ticket, /^st1\.[A-Za-z0-9_-]+$/);
52 assert.equal(expires_at, new Date(NOW + TICKET_SECONDS * 1000).toISOString());
53 assert.ok(!ticket.includes("g1t_web"), "the token is not readable in the ticket");
54 assert.deepEqual(await openTicket(SECRET, ticket, CHAT, NOW + 1000), { token: "g1t_web", userId: "usr_ada" });
55 const viewer = await ticketViewer(upgrade(`${CHAT}?channel=ch_1`, ticket), SECRET, lookup, NOW + 1000);
56 assert.equal(viewer?.id, "usr_ada");
57 // The path as the browser may spell it.
58 assert.equal((await ticketViewer(upgrade("/ACME/-/chat/live/", ticket), SECRET, lookup, NOW))?.id, "usr_ada");
59});
60
61test("a ticket past its minute opens nothing", async () => {
62 reset();
63 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
64 assert.ok(await openTicket(SECRET, ticket, CHAT, NOW + (TICKET_SECONDS - 1) * 1000));
65 assert.equal(await openTicket(SECRET, ticket, CHAT, NOW + TICKET_SECONDS * 1000), null);
66 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW + 5 * 60_000), null);
67});
68
69test("a ticket opens no other socket: another kind, or another workspace's", async () => {
70 reset();
71 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
72 for (const path of ["/acme/-/artifacts/live", "/-/live", "/other/-/chat/live"]) {
73 assert.equal(await openTicket(SECRET, ticket, path, NOW), null, path);
74 assert.equal(await ticketViewer(upgrade(path, ticket), SECRET, lookup, NOW), null, path);
75 }
76});
77
78test("a ticket changed in any way, or sealed with another key, opens nothing", async () => {
79 reset();
80 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
81 const body = ticket.slice(4);
82 for (let i = 0; i < body.length; i += 7) {
83 const swapped = body[i] === "A" ? "B" : "A";
84 const tampered = `st1.${body.slice(0, i)}${swapped}${body.slice(i + 1)}`;
85 assert.equal(await openTicket(SECRET, tampered, CHAT, NOW), null, `byte ${i}`);
86 }
87 for (const bad of ["", "st1.", "st1.!!!", `st2.${body}`, body, `st1.${body}x`, `st1.${"A".repeat(3000)}`]) {
88 assert.equal(await openTicket(SECRET, bad, CHAT, NOW), null, bad.slice(0, 20));
89 }
90 assert.equal(await openTicket("another-secret", ticket, CHAT, NOW), null);
91});
92
93test("the token is checked again when the socket opens: deleted, revoked or without the website permission, it opens nothing", async () => {
94 reset();
95 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
96 // Deleted, expired or revoked: identity knows it no more.
97 delete table.g1t_web;
98 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW), null);
99 // "Use the website as you" turned off since the page loaded.
100 table.g1t_web = { ...ada, token: { token_id: "tok_web", scopes: ["repo:read"], website: false } };
101 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW), null);
102 // A token that now names someone else.
103 table.g1t_web = { ...ada, id: "usr_bob", username: "bob" };
104 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW), null);
105 // A ticket made for something that is not a token.
106 const odd = await issueTicket(SECRET, { token: "a".repeat(64), userId: "usr_ada", path: CHAT }, NOW);
107 table["a".repeat(64)] = ada;
108 assert.equal(await ticketViewer(upgrade(CHAT, odd.ticket), SECRET, lookup, NOW), null);
109});
110
111test("a ticket is never taken by anything but a socket's upgrade", async () => {
112 reset();
113 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
114 // The socket's own address, asked without an upgrade.
115 assert.equal(await ticketViewer(upgrade(CHAT, ticket, {}), SECRET, lookup, NOW), null);
116 assert.equal(await ticketViewer(upgrade(CHAT, ticket, { upgrade: "h2c" }), SECRET, lookup, NOW), null);
117 // Pages, data requests and form posts: the token rules see no token, so the session cookie (or no one) decides.
118 for (const path of ["/acme", "/acme/-/chat", "/acme/-/chat.data", "/settings/tokens"]) {
119 const page = new Request(`https://g1t.sh${path}?ticket=${encodeURIComponent(ticket)}`);
120 assert.deepEqual(await tokenVerdict(page, async () => ada), { kind: "none" }, path);
121 const post = new Request(`https://g1t.sh${path}?ticket=${encodeURIComponent(ticket)}`, { method: "POST", body: new FormData() });
122 assert.deepEqual(await tokenVerdict(post, async () => ada), { kind: "none" }, path);
123 }
124 // An upgrade without a ticket is the session's business, as before.
125 assert.equal(await ticketViewer(upgrade(CHAT, null), SECRET, lookup, NOW), null);
126});
127
128test("a session's page opens its sockets at once and adds no ticket; a token's page asks for one first", async () => {
129 const site = new URL("https://g1t.sh/acme/-/chat");
130 const was = { location: globalThis.location, fetch: globalThis.fetch };
131 Object.defineProperty(globalThis, "location", { value: site, configurable: true });
132 const asked: string[] = [];
133 globalThis.fetch = (async (input: string) => {
134 asked.push(String(input));
135 return Response.json({ ticket: "st1.abc", expires_at: "2026-10-09T12:01:00.000Z" });
136 }) as typeof fetch;
137 try {
138 assert.equal(liveAddress(CHAT, { channel: "ch_1" }, null), "wss://g1t.sh/acme/-/chat/live?channel=ch_1");
139
140 setLiveViaToken(false);
141 const opened: string[] = [];
142 openLive(CHAT, () => ({ channel: "ch_1" }), (address) => opened.push(address), () => false);
143 assert.deepEqual(opened, ["wss://g1t.sh/acme/-/chat/live?channel=ch_1"], "synchronously, with no ticket");
144 assert.deepEqual(asked, []);
145
146 setLiveViaToken(true);
147 const viaToken = await new Promise<string>((resolve) => openLive("/-/live", () => ({ workspace: "acme" }), resolve, () => false));
148 assert.equal(viaToken, "wss://g1t.sh/-/live?workspace=acme&ticket=st1.abc");
149 assert.deepEqual(asked, ["/-/live/ticket?path=%2F-%2Flive"]);
150 // The page's copy of the route and parameter, as the server has them.
151 assert.equal(TICKET_ROUTE, "/-/live/ticket");
152 assert.equal(TICKET_PARAM, "ticket");
153
154 // Closed while the ticket was on its way: nothing opens.
155 let late = false;
156 openLive(CHAT, () => ({}), () => (late = true), () => true);
157 await new Promise((resolve) => setTimeout(resolve, 10));
158 assert.equal(late, false);
159 } finally {
160 setLiveViaToken(false);
161 Object.defineProperty(globalThis, "location", { value: was.location, configurable: true });
162 globalThis.fetch = was.fetch;
163 }
164});