Skip to content

Commit

Merge branch 'socket-tickets'

syntaqxcommitted Parents88d0b21d6356d9Browse files
15 files+591−190/15 viewed
+11−0
724724 expired, or does not have **Use the website as you** loads pages as
725725 someone signed out, with a `WWW-Authenticate` header saying the token was
726726 refused; the data requests and form posts pages make answer `401`.
727+- **Live features work too.** Chat, presence, notifications and editing an
728+ artifact with others run over WebSockets, which cannot carry the header.
729+ So a page opened with a token asks `GET /-/live/ticket` (with the
730+ header) for a socket ticket just before it opens each socket, and adds
731+ it to the socket's address. A ticket lasts 60 seconds, opens only the
732+ socket it was made for, and is never accepted by a page, a data request
733+ or the API. When the socket opens, the token is checked again, so a
734+ token deleted, expired, revoked or without **Use the website as you**
735+ opens nothing. Your automation does nothing for this: route the header
736+ to g1t.sh as above, and the page asks for its tickets itself. A session
737+ in a browser never uses tickets.
727738 - **Form posts need nothing more.** Browsers never send the header by
728739 themselves, so a post with it needs no other proof it came from g1t.sh.
729740 A post from another site is still refused.
+18−4
33 import type { ChatLiveEvent } from "@g1t/contracts";
44
55 import { backoff } from "../../lib/chat";
6+import { openLive } from "../../lib/live-socket";
67 import { heldOpen } from "../../lib/notify-store";
78
89 /** How long a conversation's socket stays open after leaving it, while the next one opens. */
3334 let timer: ReturnType<typeof setTimeout> | null = null;
3435 let closed = false;
3536 let opened = false;
37+ // Between asking for a socket ticket and opening the socket (lib/live-socket.ts).
38+ let opening = false;
3639 const connect = () => {
37− if (closed) return;
40+ if (closed || opening) return;
3841 timer = null;
39− const scheme = location.protocol === "https:" ? "wss:" : "ws:";
42+ opening = true;
43+ openLive(
44+ `/${slug}/-/chat/live`,
45+ () => ({ channel: channelId }),
46+ (address) => {
47+ opening = false;
48+ open(address);
49+ },
50+ () => closed,
51+ );
52+ };
53+ const open = (address: string) => {
4054 let ws: WebSocket;
4155 try {
42− ws = new WebSocket(`${scheme}//${location.host}/${slug}/-/chat/live?channel=${encodeURIComponent(channelId)}`);
56+ ws = new WebSocket(address);
4357 } catch {
4458 schedule();
4559 return;
7690 };
7791 // Back now, not after the wait: the tab is shown, or the network returned.
7892 const now = () => {
79− if (closed || socket.current || document.visibilityState !== "visible") return;
93+ if (closed || opening || socket.current || document.visibilityState !== "visible") return;
8094 if (timer) clearTimeout(timer);
8195 timer = null;
8296 attempt = 0;
+25−3
1818 import * as syncProtocol from "y-protocols/sync";
1919 import * as Y from "yjs";
2020
21+import { openLive } from "../../lib/live-socket";
2122 import { heldOpen } from "../../lib/notify-store";
2223
2324 const MESSAGE_SYNC = 0;
3738 private timer: ReturnType<typeof setTimeout> | null = null;
3839 private keepalive: ReturnType<typeof setInterval> | null = null;
3940 private stopped = false;
41+ /** Between asking for a socket ticket and opening the socket. */
42+ private opening = false;
4043 private readonly statusListeners = new Set<(status: LiveStatus) => void>();
4144 private readonly eventListeners = new Set<(event: FoliosLiveEvent) => void>();
4245
7376 }
7477
7578 private connect() {
76− if (this.stopped) return;
79+ if (this.stopped || this.opening) return;
7780 this.setStatus(this.attempts ? "offline" : "connecting");
78− const socket = new WebSocket(this.url);
81+ // A page opened with an access token adds a socket ticket first (lib/live-socket.ts).
82+ const target = new URL(this.url);
83+ this.opening = true;
84+ openLive(
85+ target.pathname,
86+ () => Object.fromEntries(target.searchParams),
87+ (address) => {
88+ this.opening = false;
89+ this.open(address);
90+ },
91+ () => {
92+ if (!this.stopped) return false;
93+ this.opening = false;
94+ return true;
95+ },
96+ );
97+ }
98+
99+ private open(address: string) {
100+ const socket = new WebSocket(address);
79101 socket.binaryType = "arraybuffer";
80102 this.socket = socket;
81103 socket.onopen = () => {
174196 };
175197
176198 private onOnline = () => {
177− if (this.socket || this.stopped) return;
199+ if (this.socket || this.opening || this.stopped) return;
178200 if (this.timer) clearTimeout(this.timer);
179201 this.attempts = 0;
180202 this.connect();
+64−0
1+/**
2+ * Opening the site's live sockets from the browser. A page signed in by a
3+ * session opens them at once, its cookie going along as always. A page
4+ * opened with an access token has no cookie, and a socket cannot carry the
5+ * token's header, so it first asks for a socket ticket and adds it to the
6+ * address (lib/socket-ticket.ts). Browser-only.
7+ */
8+
9+/** As lib/socket-ticket.ts's, kept apart so the page does not load its sealing code. */
10+const TICKET_PARAM = "ticket";
11+const TICKET_ROUTE = "/-/live/ticket";
12+
13+let viaToken = false;
14+
15+/** Set by the root as it renders: whether this page was opened with an access token. */
16+export function setLiveViaToken(on: boolean): void {
17+ viaToken = on;
18+}
19+
20+/** `wss://<this site><path>?<params>`, with a ticket when one was given. */
21+export function liveAddress(path: string, params: Record<string, string | null | undefined>, ticket: string | null): string {
22+ const url = new URL(path, location.href);
23+ url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
24+ for (const [name, value] of Object.entries(params)) if (value != null && value !== "") url.searchParams.set(name, value);
25+ if (ticket) url.searchParams.set(TICKET_PARAM, ticket);
26+ return url.toString();
27+}
28+
29+async function ticketFor(path: string): Promise<string | null> {
30+ try {
31+ const answer = await fetch(`${TICKET_ROUTE}?path=${encodeURIComponent(path)}`, {
32+ headers: { accept: "application/json" },
33+ cache: "no-store",
34+ credentials: "same-origin",
35+ });
36+ if (!answer.ok) return null;
37+ const body = (await answer.json()) as { ticket?: unknown };
38+ return typeof body.ticket === "string" ? body.ticket : null;
39+ } catch {
40+ return null;
41+ }
42+}
43+
44+/**
45+ * Calls `open` with the address of the socket at `path`: at once for a
46+ * session, after fetching a fresh ticket for a token's page (each attempt
47+ * gets its own, as one lasts a minute). `params` is read when the address
48+ * is made, so it sees any change meanwhile. Nothing is opened once
49+ * `cancelled` says so.
50+ */
51+export function openLive(
52+ path: string,
53+ params: () => Record<string, string | null | undefined>,
54+ open: (address: string) => void,
55+ cancelled: () => boolean,
56+): void {
57+ if (!viaToken) {
58+ open(liveAddress(path, params(), null));
59+ return;
60+ }
61+ void ticketFor(path).then((ticket) => {
62+ if (!cancelled()) open(liveAddress(path, params(), ticket));
63+ });
64+}
+25−5
1919
2020 import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry } from "@g1t/contracts";
2121
22+import { openLive } from "./live-socket";
2223 import { isIdle, mergePeople } from "./presence";
2324
2425 import {
397398 }
398399 }
399400
401+/** Between asking for a socket ticket and opening the socket (lib/live-socket.ts). */
402+let opening = false;
403+
400404 function connect(): void {
401− if (!running) return;
405+ if (!running || opening) return;
402406 timer = null;
403− const scheme = location.protocol === "https:" ? "wss:" : "ws:";
404− const query = workspace ? `?workspace=${encodeURIComponent(workspace)}` : "";
407+ opening = true;
408+ openLive(
409+ "/-/live",
410+ // Read when the socket opens: the workspace may change meanwhile.
411+ () => ({ workspace }),
412+ (address) => {
413+ opening = false;
414+ open(address);
415+ },
416+ () => {
417+ if (running) return false;
418+ opening = false;
419+ return true;
420+ },
421+ );
422+}
423+
424+function open(address: string): void {
405425 let ws: WebSocket;
406426 try {
407− ws = new WebSocket(`${scheme}//${location.host}/-/live${query}`);
427+ ws = new WebSocket(address);
408428 } catch {
409429 schedule();
410430 return;
442462 /** Back now: the tab is shown, or the network returned. */
443463 function now(): void {
444464 sendState();
445− if (!running || socket || document.visibilityState !== "visible") return;
465+ if (!running || opening || socket || document.visibilityState !== "visible") return;
446466 if (timer) clearTimeout(timer);
447467 timer = null;
448468 attempt = 0;
+36−0
1+import { env } from "cloudflare:workers";
2+
3+import type { User } from "@g1t/contracts";
4+
5+import { getViewer } from "./session.server";
6+import { identity } from "./services.server";
7+import { ticketViewer } from "./socket-ticket";
8+import { websiteUser } from "./website-token";
9+
10+let isolateSecret: string | null = null;
11+
12+/**
13+ * What tickets are sealed with: the site's `USERCONTENT_KEY`, from which
14+ * lib/socket-ticket.ts derives a key of their own. Without it (a local
15+ * run), a key made for this isolate, which is enough where one process
16+ * serves the site.
17+ */
18+export function ticketSecret(): string {
19+ if (env.USERCONTENT_KEY) return env.USERCONTENT_KEY;
20+ if (!isolateSecret) {
21+ const bytes = crypto.getRandomValues(new Uint8Array(32));
22+ isolateSecret = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
23+ }
24+ return isolateSecret;
25+}
26+
27+/**
28+ * Who opens a live socket: the session or token the request carries, as
29+ * on any page, or else the person a socket ticket was made for
30+ * (lib/socket-ticket.ts), whose token is checked again now.
31+ */
32+export async function socketViewer(context: Parameters<typeof getViewer>[0], request: Request): Promise<User | null> {
33+ const viewer = getViewer(context);
34+ if (viewer) return viewer;
35+ return ticketViewer(request, ticketSecret(), async (token) => websiteUser(await identity.userForAccessToken(token)));
36+}
+164−0
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 { liveAddress, openLive, setLiveViaToken } from "./live-socket.ts";
7+import { TICKET_PARAM, TICKET_ROUTE, TICKET_SECONDS, issueTicket, openTicket, socketPath, ticketViewer } from "./socket-ticket.ts";
8+import { tokenVerdict, websiteUser } from "./website-token.ts";
9+
10+const SECRET = "site-secret";
11+const NOW = Date.UTC(2026, 9, 9, 12, 0, 0);
12+
13+const 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. */
23+const table: Record<string, Viewer> = {};
24+const lookup = async (token: string) => websiteUser(table[token] ?? null);
25+
26+function reset() {
27+ for (const key of Object.keys(table)) delete table[key];
28+ table.g1t_web = ada;
29+}
30+
31+function 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+
37+const CHAT = "/acme/-/chat/live";
38+
39+test("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+
48+test("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+
61+test("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+
69+test("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+
78+test("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+
93+test("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+
111+test("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+
128+test("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+});
+189−0
1+/**
2+ * Live sockets for a page opened with an access token (lib/website-token.ts).
3+ *
4+ * A browser cannot put an `Authorization` header on a WebSocket, so a page
5+ * a token opened has no way to sign its sockets in: there is no session
6+ * cookie. Instead, just before it opens one, the page asks
7+ * `GET /-/live/ticket?path=<socket path>` (a normal request, which carries
8+ * the token like any other) for a socket ticket, and adds it to the
9+ * socket's address as `?ticket=`. Sessions never ask: their sockets carry
10+ * the cookie as they always have.
11+ *
12+ * A ticket:
13+ * - is good for {@link TICKET_SECONDS} seconds, and for one socket path
14+ * alone (`/-/live`, `/<workspace>/-/chat/live` or
15+ * `/<workspace>/-/artifacts/live`);
16+ * - is read only by those sockets' upgrade, never by a page, a data
17+ * request, a form post or the API ({@link ticketViewer} ignores any
18+ * request that is not a WebSocket upgrade);
19+ * - holds the token itself, encrypted and authenticated (AES-GCM) under a
20+ * key only the site has, so the upgrade checks the token exactly as a
21+ * page request does: deleted, expired, revoked by a workspace or with
22+ * "Use the website as you" turned off, it opens nothing, even inside the
23+ * ticket's minute;
24+ * - is never stored, logged or passed on: the socket handlers build the
25+ * service's address afresh, without it.
26+ *
27+ * No Workers imports, so it is tested under Node.
28+ */
29+
30+import type { User } from "@g1t/contracts";
31+
32+/** How long a ticket is good for. */
33+export const TICKET_SECONDS = 60;
34+
35+/** The query parameter a socket's address carries a ticket in. */
36+export const TICKET_PARAM = "ticket";
37+
38+/** Where a page asks for one. */
39+export const TICKET_ROUTE = "/-/live/ticket";
40+
41+const PREFIX = "st1.";
42+
43+/**
44+ * A socket path as the routes match it (any case, no doubled or trailing
45+ * slashes), when it is one of the site's live sockets; else null.
46+ */
47+export function socketPath(pathname: string): { path: string; workspace: string | null } | null {
48+ let path = pathname;
49+ try {
50+ path = decodeURIComponent(path);
51+ } catch {
52+ return null;
53+ }
54+ path = path.toLowerCase().replace(/\/{2,}/g, "/");
55+ if (path.length > 1) path = path.replace(/\/+$/, "");
56+ if (path === "/-/live") return { path, workspace: null };
57+ const match = /^\/([^/]+)\/-\/(?:chat|artifacts)\/live$/.exec(path);
58+ if (!match || match[1] === "-") return null;
59+ return { path, workspace: match[1]! };
60+}
61+
62+const keys = new Map<string, Promise<CryptoKey>>();
63+
64+/**
65+ * The ticket key, derived from the site's secret for this one use (so it
66+ * never doubles as the key the secret is otherwise for).
67+ */
68+function ticketKey(secret: string): Promise<CryptoKey> {
69+ let key = keys.get(secret);
70+ if (!key) {
71+ key = crypto.subtle
72+ .importKey("raw", new TextEncoder().encode(secret), "HKDF", false, ["deriveKey"])
73+ .then((base) =>
74+ crypto.subtle.deriveKey(
75+ { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("g1t"), info: new TextEncoder().encode("socket ticket v1") },
76+ base,
77+ { name: "AES-GCM", length: 256 },
78+ false,
79+ ["encrypt", "decrypt"],
80+ ),
81+ );
82+ keys.set(secret, key);
83+ }
84+ return key;
85+}
86+
87+/** Binds the ciphertext to the path, so a ticket opens nowhere else. */
88+function bound(path: string): Uint8Array<ArrayBuffer> {
89+ return new TextEncoder().encode(`g1t socket ticket\n${path}`);
90+}
91+
92+function base64url(bytes: Uint8Array): string {
93+ let text = "";
94+ for (const byte of bytes) text += String.fromCharCode(byte);
95+ return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
96+}
97+
98+function fromBase64url(text: string): Uint8Array<ArrayBuffer> | null {
99+ if (!/^[A-Za-z0-9_-]+$/.test(text)) return null;
100+ try {
101+ const raw = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
102+ const bytes = new Uint8Array(raw.length);
103+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
104+ return bytes;
105+ } catch {
106+ return null;
107+ }
108+}
109+
110+type Sealed = { t: string; u: string; p: string; e: number };
111+
112+/**
113+ * A ticket for one socket path, for the token a page request carried and
114+ * the person it resolved to. `path` is a {@link socketPath}.
115+ */
116+export async function issueTicket(
117+ secret: string,
118+ input: { token: string; userId: string; path: string },
119+ nowMs = Date.now(),
120+): Promise<{ ticket: string; expires_at: string }> {
121+ const expires = Math.floor(nowMs / 1000) + TICKET_SECONDS;
122+ const sealed: Sealed = { t: input.token, u: input.userId, p: input.path, e: expires };
123+ const iv = crypto.getRandomValues(new Uint8Array(12));
124+ const body = await crypto.subtle.encrypt(
125+ { name: "AES-GCM", iv, additionalData: bound(input.path) },
126+ await ticketKey(secret),
127+ new TextEncoder().encode(JSON.stringify(sealed)),
128+ );
129+ const out = new Uint8Array(iv.length + body.byteLength);
130+ out.set(iv, 0);
131+ out.set(new Uint8Array(body), iv.length);
132+ return { ticket: PREFIX + base64url(out), expires_at: new Date(expires * 1000).toISOString() };
133+}
134+
135+/**
136+ * The token and person a ticket was made for, when it is genuine, for
137+ * this socket path, and not past its minute; else null.
138+ */
139+export async function openTicket(
140+ secret: string,
141+ ticket: string,
142+ path: string,
143+ nowMs = Date.now(),
144+): Promise<{ token: string; userId: string } | null> {
145+ if (!ticket.startsWith(PREFIX) || ticket.length > 2048) return null;
146+ const bytes = fromBase64url(ticket.slice(PREFIX.length));
147+ if (!bytes || bytes.length <= 12 + 16) return null;
148+ let sealed: Sealed;
149+ try {
150+ const plain = await crypto.subtle.decrypt(
151+ { name: "AES-GCM", iv: bytes.slice(0, 12), additionalData: bound(path) },
152+ await ticketKey(secret),
153+ bytes.slice(12),
154+ );
155+ sealed = JSON.parse(new TextDecoder().decode(plain)) as Sealed;
156+ } catch {
157+ // Tampered with, made for another path, or under another key.
158+ return null;
159+ }
160+ if (typeof sealed?.t !== "string" || typeof sealed.u !== "string" || sealed.p !== path || typeof sealed.e !== "number") return null;
161+ if (sealed.e * 1000 <= nowMs) return null;
162+ return { token: sealed.t, userId: sealed.u };
163+}
164+
165+/**
166+ * The person a socket's ticket signs in, checked as a page request with
167+ * the token would be: `lookup` is identity's `user_for_access_token`
168+ * narrowed by lib/website-token.ts's `websiteUser`.
169+ * Null for anything but a WebSocket upgrade to the socket the ticket was
170+ * made for, and for a ticket that is not genuine, has expired, or whose
171+ * token no longer may use the website.
172+ */
173+export async function ticketViewer(
174+ request: Request,
175+ secret: string,
176+ lookup: (token: string) => Promise<User | null>,
177+ nowMs = Date.now(),
178+): Promise<User | null> {
179+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return null;
180+ const url = new URL(request.url);
181+ const ticket = url.searchParams.get(TICKET_PARAM);
182+ if (!ticket) return null;
183+ const socket = socketPath(url.pathname);
184+ if (!socket) return null;
185+ const opened = await openTicket(secret, ticket, socket.path, nowMs);
186+ if (!opened || !opened.token.startsWith("g1t_")) return null;
187+ const user = await lookup(opened.token);
188+ return user && user.id === opened.userId ? user : null;
189+}
+3−0
7272 import { RELOADED_KEY, RELOAD_GIVE_UP_MS, clientNavigated, reloadFixes, reloadedBefore } from "./lib/stale-build";
7373 import { useNonce } from "./lib/nonce";
7474 import { isNeedsSignIn } from "./lib/website-token";
75+import { setLiveViaToken } from "./lib/live-socket";
7576 import { LiveNotifications } from "./components/notifications/live-notifications";
7677
7778
554555 if (loaded && inBrowser) lastRoot = loaded;
555556 const root = loaded ?? (inBrowser ? lastRoot : undefined);
556557 const user = root?.user;
558+ // A page opened with an access token signs its live sockets in with tickets (lib/live-socket.ts).
559+ if (inBrowser) setLiveViaToken(Boolean(user?.token?.website));
557560 const { pathname, search } = useLocation();
558561 // Drawn around the error page too: a 404 keeps the sidebar out of a
559562 // project or workspace the viewer cannot see.
+3−0
5656 // Live notifications: each tab's feed socket, and the person's
5757 // notification settings as JSON (services/notify).
5858 route("-/live", "routes/notify/live.ts"),
59+ // A page opened with an access token asks here for each socket's
60+ // short-lived ticket (lib/socket-ticket.ts).
61+ route("-/live/ticket", "routes/notify/ticket.ts"),
5962 route("-/notify", "routes/notify/api.ts"),
6063 route("explore", "routes/explore.tsx", { id: "explore" }),
6164 route("pricing", "routes/pricing.tsx"),
+4−2
44
55 import type { Route } from "./+types/live";
66 import { chat, inbox } from "../../lib/services.server";
7−import { getViewer, roleIn } from "../../lib/session.server";
7+import { roleIn } from "../../lib/session.server";
8+import { socketViewer } from "../../lib/socket-ticket.server";
89
910 /** The longest the counts read for a new socket hold it up. */
1011 const SEED_WAIT_MS = 800;
4344 * hibernating while nothing happens).
4445 */
4546 export async function loader({ context, request }: Route.LoaderArgs) {
46− const viewer = getViewer(context);
47+ // A session, or a page opened with a token by its socket ticket.
48+ const viewer = await socketViewer(context, request);
4749 if (!viewer) return new Response("Sign in first.", { status: 401 });
4850 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
4951 return new Response("This address takes a WebSocket.", { status: 426, headers: { upgrade: "websocket" } });
+32−0
1+import type { Route } from "./+types/ticket";
2+import { getViewer, roleIn } from "../../lib/session.server";
3+import { issueTicket, socketPath } from "../../lib/socket-ticket";
4+import { ticketSecret } from "../../lib/socket-ticket.server";
5+import { bearerToken } from "../../lib/website-token";
6+
7+const PRIVATE = { "cache-control": "no-store", "x-robots-tag": "noindex" };
8+
9+/**
10+ * A socket ticket for a page opened with an access token:
11+ * `GET /-/live/ticket?path=/<workspace>/-/chat/live` answers
12+ * `{ ticket, expires_at }`, good for a minute on that socket alone
13+ * (lib/socket-ticket.ts). Only a request signed in by a token gets one: a
14+ * session's sockets carry its cookie and need none.
15+ */
16+export async function loader({ context, request }: Route.LoaderArgs) {
17+ const viewer = getViewer(context);
18+ const token = bearerToken(request);
19+ if (!viewer || !token || !viewer.token?.website) {
20+ return Response.json(
21+ { message: "Socket tickets are for pages opened with an access token; a signed-in browser's sockets use its session." },
22+ { status: viewer ? 400 : 401, headers: PRIVATE },
23+ );
24+ }
25+ const socket = socketPath(new URL(request.url).searchParams.get("path") ?? "");
26+ if (!socket) return Response.json({ message: "Which socket? `path` is one of the site's live sockets." }, { status: 400, headers: PRIVATE });
27+ if (socket.workspace && !roleIn(viewer, socket.workspace)) {
28+ return Response.json({ message: "Not found" }, { status: 404, headers: PRIVATE });
29+ }
30+ const issued = await issueTicket(ticketSecret(), { token, userId: viewer.id, path: socket.path });
31+ return Response.json(issued, { headers: PRIVATE });
32+}
+4−2
33 import { CHAT_VIEWER_HEADER } from "@g1t/contracts";
44
55 import type { Route } from "./+types/live";
6−import { getViewer, roleIn } from "../../../lib/session.server";
6+import { roleIn } from "../../../lib/session.server";
7+import { socketViewer } from "../../../lib/socket-ticket.server";
78
89 /**
910 * A conversation's live socket: `wss://<site>/<workspace>/-/chat/live?channel=<id>`.
1314 * channel, hibernating while nothing happens).
1415 */
1516 export async function loader({ params, context, request }: Route.LoaderArgs) {
16− const viewer = getViewer(context);
17+ // A session, or a page opened with a token by its socket ticket.
18+ const viewer = await socketViewer(context, request);
1719 if (!viewer) return new Response("Sign in to use chat.", { status: 401 });
1820 if (!roleIn(viewer, params.owner)) return new Response("Not found", { status: 404 });
1921 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
+4−2
33 import { DOCS_VIEWER_HEADER } from "@g1t/contracts";
44
55 import type { Route } from "./+types/live";
6−import { getViewer, roleIn } from "../../../lib/session.server";
6+import { roleIn } from "../../../lib/session.server";
7+import { socketViewer } from "../../../lib/socket-ticket.server";
78
89 /**
910 * An artifact's live socket: `wss://<site>/<workspace>/-/artifacts/live?folio=<id>`.
1314 * Object per folio, any kind), which enforces that role.
1415 */
1516 export async function loader({ params, context, request }: Route.LoaderArgs) {
16− const viewer = getViewer(context);
17+ // A session, or a page opened with a token by its socket ticket.
18+ const viewer = await socketViewer(context, request);
1719 if (!viewer) return new Response("Sign in to use Artifacts.", { status: 401 });
1820 if (!roleIn(viewer, params.owner)) return new Response("Not found", { status: 404 });
1921 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
+9−1
5858 | `WEB_ADDRESS_LIMIT` | 4204 | 3,000 | address | web: every request that reaches the Worker; stops made-up cookies getting round the signed-out limit |
5959 | `GIT_ANONYMOUS_LIMIT` | 4205 | 120 | address | web: git smart HTTP without `Authorization` (~40 clones) |
6060 | `GIT_SIGNED_LIMIT` | 4206 | 1,200 | `Authorization` hash | web: git smart HTTP with credentials, sandboxes' included |
61−| `WEB_TOKEN_LIMIT` | 4207 | 1,000 | token hash | web: pages and data requests with `Authorization: Bearer` (a token used on the website, not checked there); the same limit as `API_TOKEN_LIMIT`, counted apart |
61+| `WEB_TOKEN_LIMIT` | 4207 | 1,000 | token hash | web: pages, data requests and socket tickets with `Authorization: Bearer` (a token used on the website, not checked there); the same limit as `API_TOKEN_LIMIT`, counted apart. The live sockets such a page opens carry a ticket instead of the header (`app/lib/socket-ticket.ts`), so their upgrades count as signed out, by address |
6262 | `PACK_FILL_LIMIT` | 4301 | 30 | repository id | repos `src/limits.rs`: packs written to `GIT_PACKS`; past it the pack is streamed, not kept |
6363 | `ANONYMOUS_FETCH_LIMIT` | 4302 | 120 | repository id | repos: anonymous fetches the git store answers (cache hits never count) |
6464 | `API_ANONYMOUS_LIMIT` | 4401 | 60 | `rest:`/`mcp:` + address | api `src/limits.rs`: no token, or a wrong one |
103103 no log line. Error counts in the dashboard still show it, and a recurring
104104 one shows up within a few occurrences. To chase a rare error on one of
105105 those Workers, raise its rate to 1 for the investigation and lower it after.
106+
107+A sampled invocation's log holds its full URL. The only credential g1t
108+ever puts in a URL is a socket ticket (`?ticket=` on `/-/live`,
109+`/<workspace>/-/chat/live` and `/<workspace>/-/artifacts/live`, for pages
110+opened with an access token; `apps/web/app/lib/socket-ticket.ts`). The site
111+never logs it or passes it on, and one found in Workers Logs is useless:
112+it lasts 60 seconds, opens only that socket, and the token inside it is
113+sealed and checked again when the socket opens.