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 | ||
| 31 | import type { User } from "@g1t/contracts"; | |
| 32 | ||
| 33 | /** How long a ticket is good for. */ | |
| 34 | export const TICKET_SECONDS = 60; | |
| 35 | ||
| 36 | /** The query parameter a socket's address carries a ticket in. */ | |
| 37 | export const TICKET_PARAM = "ticket"; | |
| 38 | ||
| 39 | /** Where a page asks for one. */ | |
| 40 | export const TICKET_ROUTE = "/-/live/ticket"; | |
| 41 | ||
| 42 | const 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 | */ | |
| 48 | export 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 | ||
| 63 | const 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 | */ | |
| 69 | function 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. */ | |
| 89 | function bound(path: string): Uint8Array<ArrayBuffer> { | |
| 90 | return new TextEncoder().encode(`g1t socket ticket\n${path}`); | |
| 91 | } | |
| 92 | ||
| 93 | function 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 | ||
| 99 | function 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 | ||
| 111 | type 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 | */ | |
| 117 | export 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 | */ | |
| 140 | export 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 | */ | |
| 174 | export 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.