Skip to content

Commit

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.

syntaqxcommitted Parent180b34aBrowse 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, reloadFixes } 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.