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

Merge branch 'socket-tickets'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
Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More.22 * "Use the website as you" turned off (or, for a token let in by its
23 * `chat:read` scope, without it), it opens nothing, even inside the
Merge branch 'socket-tickets'24 * ticket's minute;
25 * - is never stored, logged or passed on: the socket handlers build the
26 * service's address afresh, without it.
27 *
28 * No Workers imports, so it is tested under Node.
29 */
30
31import type { User } from "@g1t/contracts";
32
33/** How long a ticket is good for. */
34export const TICKET_SECONDS = 60;
35
36/** The query parameter a socket's address carries a ticket in. */
37export const TICKET_PARAM = "ticket";
38
39/** Where a page asks for one. */
40export const TICKET_ROUTE = "/-/live/ticket";
41
42const PREFIX = "st1.";
43
44/**
45 * A socket path as the routes match it (any case, no doubled or trailing
46 * slashes), when it is one of the site's live sockets; else null.
47 */
48export function socketPath(pathname: string): { path: string; workspace: string | null } | null {
49 let path = pathname;
50 try {
51 path = decodeURIComponent(path);
52 } catch {
53 return null;
54 }
55 path = path.toLowerCase().replace(/\/{2,}/g, "/");
56 if (path.length > 1) path = path.replace(/\/+$/, "");
57 if (path === "/-/live") return { path, workspace: null };
58 const match = /^\/([^/]+)\/-\/(?:chat|artifacts)\/live$/.exec(path);
59 if (!match || match[1] === "-") return null;
60 return { path, workspace: match[1]! };
61}
62
63const keys = new Map<string, Promise<CryptoKey>>();
64
65/**
66 * The ticket key, derived from the site's secret for this one use (so it
67 * never doubles as the key the secret is otherwise for).
68 */
69function ticketKey(secret: string): Promise<CryptoKey> {
70 let key = keys.get(secret);
71 if (!key) {
72 key = crypto.subtle
73 .importKey("raw", new TextEncoder().encode(secret), "HKDF", false, ["deriveKey"])
74 .then((base) =>
75 crypto.subtle.deriveKey(
76 { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("g1t"), info: new TextEncoder().encode("socket ticket v1") },
77 base,
78 { name: "AES-GCM", length: 256 },
79 false,
80 ["encrypt", "decrypt"],
81 ),
82 );
83 keys.set(secret, key);
84 }
85 return key;
86}
87
88/** Binds the ciphertext to the path, so a ticket opens nowhere else. */
89function bound(path: string): Uint8Array<ArrayBuffer> {
90 return new TextEncoder().encode(`g1t socket ticket\n${path}`);
91}
92
93function base64url(bytes: Uint8Array): string {
94 let text = "";
95 for (const byte of bytes) text += String.fromCharCode(byte);
96 return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
97}
98
99function fromBase64url(text: string): Uint8Array<ArrayBuffer> | null {
100 if (!/^[A-Za-z0-9_-]+$/.test(text)) return null;
101 try {
102 const raw = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
103 const bytes = new Uint8Array(raw.length);
104 for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
105 return bytes;
106 } catch {
107 return null;
108 }
109}
110
111type Sealed = { t: string; u: string; p: string; e: number };
112
113/**
114 * A ticket for one socket path, for the token a page request carried and
115 * the person it resolved to. `path` is a {@link socketPath}.
116 */
117export async function issueTicket(
118 secret: string,
119 input: { token: string; userId: string; path: string },
120 nowMs = Date.now(),
121): Promise<{ ticket: string; expires_at: string }> {
122 const expires = Math.floor(nowMs / 1000) + TICKET_SECONDS;
123 const sealed: Sealed = { t: input.token, u: input.userId, p: input.path, e: expires };
124 const iv = crypto.getRandomValues(new Uint8Array(12));
125 const body = await crypto.subtle.encrypt(
126 { name: "AES-GCM", iv, additionalData: bound(input.path) },
127 await ticketKey(secret),
128 new TextEncoder().encode(JSON.stringify(sealed)),
129 );
130 const out = new Uint8Array(iv.length + body.byteLength);
131 out.set(iv, 0);
132 out.set(new Uint8Array(body), iv.length);
133 return { ticket: PREFIX + base64url(out), expires_at: new Date(expires * 1000).toISOString() };
134}
135
136/**
137 * The token and person a ticket was made for, when it is genuine, for
138 * this socket path, and not past its minute; else null.
139 */
140export async function openTicket(
141 secret: string,
142 ticket: string,
143 path: string,
144 nowMs = Date.now(),
145): Promise<{ token: string; userId: string } | null> {
146 if (!ticket.startsWith(PREFIX) || ticket.length > 2048) return null;
147 const bytes = fromBase64url(ticket.slice(PREFIX.length));
148 if (!bytes || bytes.length <= 12 + 16) return null;
149 let sealed: Sealed;
150 try {
151 const plain = await crypto.subtle.decrypt(
152 { name: "AES-GCM", iv: bytes.slice(0, 12), additionalData: bound(path) },
153 await ticketKey(secret),
154 bytes.slice(12),
155 );
156 sealed = JSON.parse(new TextDecoder().decode(plain)) as Sealed;
157 } catch {
158 // Tampered with, made for another path, or under another key.
159 return null;
160 }
161 if (typeof sealed?.t !== "string" || typeof sealed.u !== "string" || sealed.p !== path || typeof sealed.e !== "number") return null;
162 if (sealed.e * 1000 <= nowMs) return null;
163 return { token: sealed.t, userId: sealed.u };
164}
165
166/**
167 * The person a socket's ticket signs in, checked as a page request with
168 * the token would be: `lookup` is identity's `user_for_access_token`
Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More.169 * narrowed by lib/website-token.ts's `socketUser` for the socket's path.
Merge branch 'socket-tickets'170 * Null for anything but a WebSocket upgrade to the socket the ticket was
171 * made for, and for a ticket that is not genuine, has expired, or whose
172 * token no longer may use the website.
173 */
174export async function ticketViewer(
175 request: Request,
176 secret: string,
Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More.177 lookup: (token: string, path: string) => Promise<User | null>,
Merge branch 'socket-tickets'178 nowMs = Date.now(),
179): Promise<User | null> {
180 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return null;
181 const url = new URL(request.url);
182 const ticket = url.searchParams.get(TICKET_PARAM);
183 if (!ticket) return null;
184 const socket = socketPath(url.pathname);
185 if (!socket) return null;
186 const opened = await openTicket(secret, ticket, socket.path, nowMs);
187 if (!opened || !opened.token.startsWith("g1t_")) return null;
Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More.188 const user = await lookup(opened.token, socket.path);
Merge branch 'socket-tickets'189 return user && user.id === opened.userId ? user : null;
190}

This file's history is long; its oldest lines are credited to the oldest commit read.