Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

228 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { User, Viewer } from "@g1t/contracts";
5
6import { liveAddress, offerTicket, openLive, setLiveViaToken, takeOfferedTicket } from "./live-socket.ts";
7import { TICKET_PARAM, TICKET_ROUTE, TICKET_SECONDS, issueTicket, openTicket, socketPath, ticketViewer } from "./socket-ticket.ts";
8import { socketUser, 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(
143 CHAT,
144 () => ({ channel: "ch_1" }),
145 (address) => opened.push(address),
146 () => false,
147 );
148 assert.deepEqual(opened, ["wss://g1t.sh/acme/-/chat/live?channel=ch_1"], "synchronously, with no ticket");
149 assert.deepEqual(asked, []);
150
151 setLiveViaToken(true);
152 const viaToken = await new Promise<string>((resolve) =>
153 openLive(
154 "/-/live",
155 () => ({ workspace: "acme" }),
156 resolve,
157 () => false,
158 ),
159 );
160 assert.equal(viaToken, "wss://g1t.sh/-/live?workspace=acme&ticket=st1.abc");
161 assert.deepEqual(asked, ["/-/live/ticket?path=%2F-%2Flive"]);
162 // The page's copy of the route and parameter, as the server has them.
163 assert.equal(TICKET_ROUTE, "/-/live/ticket");
164 assert.equal(TICKET_PARAM, "ticket");
165
166 // Closed while the ticket was on its way: nothing opens.
167 let late = false;
168 openLive(
169 CHAT,
170 () => ({}),
171 () => (late = true),
172 () => true,
173 );
174 await new Promise((resolve) => setTimeout(resolve, 10));
175 assert.equal(late, false);
176
177 // A ticket the page's loader minted opens the first socket with no
178 // request; it is used once, and not when its minute is nearly up.
179 asked.length = 0;
180 offerTicket(CHAT, { ticket: "st1.minted", expires_at: new Date(Date.now() + 50_000).toISOString() });
181 const minted: string[] = [];
182 openLive(
183 CHAT,
184 () => ({ channel: "ch_2" }),
185 (address) => minted.push(address),
186 () => false,
187 );
188 assert.deepEqual(minted, ["wss://g1t.sh/acme/-/chat/live?channel=ch_2&ticket=st1.minted"], "synchronously, from the offered ticket");
189 assert.deepEqual(asked, []);
190 const again = await new Promise<string>((resolve) =>
191 openLive(
192 CHAT,
193 () => ({ channel: "ch_2" }),
194 resolve,
195 () => false,
196 ),
197 );
198 assert.equal(again, "wss://g1t.sh/acme/-/chat/live?channel=ch_2&ticket=st1.abc", "the next socket asks, as the offer was used");
199 assert.deepEqual(asked, [`/-/live/ticket?path=${encodeURIComponent(CHAT)}`]);
200 offerTicket(CHAT, { ticket: "st1.stale", expires_at: new Date(Date.now() + 2_000).toISOString() });
201 assert.equal(takeOfferedTicket(CHAT), null, "an offer about to expire is not used");
202 offerTicket(CHAT, null);
203 assert.equal(takeOfferedTicket(CHAT), null);
204 } finally {
205 setLiveViaToken(false);
206 Object.defineProperty(globalThis, "location", { value: was.location, configurable: true });
207 globalThis.fetch = was.fetch;
208 }
209});
210
211test("a ticket for a chat-scoped token opens Chat's socket and the feed, checked again as it opens", async () => {
212 const app: User = { ...ada, token: { token_id: "tok_app", scopes: ["chat:read"] } };
213 const tokens: Record<string, Viewer> = { g1t_app: app };
214 // As the site checks it: identity's answer narrowed by `socketUser` for the socket's path.
215 const bySocket = async (token: string, path: string) => socketUser(tokens[token] ?? null, path);
216 for (const path of [CHAT, "/-/live"]) {
217 const { ticket } = await issueTicket(SECRET, { token: "g1t_app", userId: "usr_ada", path }, NOW);
218 assert.equal((await ticketViewer(upgrade(path, ticket), SECRET, bySocket, NOW))?.id, "usr_ada", path);
219 }
220 // An artifact's socket is the website's alone, even with a genuine ticket.
221 const artifacts = "/acme/-/artifacts/live";
222 const { ticket } = await issueTicket(SECRET, { token: "g1t_app", userId: "usr_ada", path: artifacts }, NOW);
223 assert.equal(await ticketViewer(upgrade(artifacts, ticket), SECRET, bySocket, NOW), null);
224 // Chat taken away since the ticket was made: the socket opens nothing.
225 const chat = await issueTicket(SECRET, { token: "g1t_app", userId: "usr_ada", path: CHAT }, NOW);
226 tokens.g1t_app = { ...app, token: { token_id: "tok_app", scopes: ["notifications:read"] } };
227 assert.equal(await ticketViewer(upgrade(CHAT, chat.ticket), SECRET, bySocket, NOW), null);
228});