Merge branch 'socket-tickets'
15 files+591−190/15 viewed
| 724 | 724 | expired, or does not have **Use the website as you** loads pages as | |
| 725 | 725 | someone signed out, with a `WWW-Authenticate` header saying the token was | |
| 726 | 726 | 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. | |
| 727 | 738 | - **Form posts need nothing more.** Browsers never send the header by | |
| 728 | 739 | themselves, so a post with it needs no other proof it came from g1t.sh. | |
| 729 | 740 | A post from another site is still refused. |
| 3 | 3 | import type { ChatLiveEvent } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import { backoff } from "../../lib/chat"; | |
| 6 | + | import { openLive } from "../../lib/live-socket"; | |
| 6 | 7 | import { heldOpen } from "../../lib/notify-store"; | |
| 7 | 8 | ||
| 8 | 9 | /** How long a conversation's socket stays open after leaving it, while the next one opens. */ | |
| ⋯ | |||
| 33 | 34 | let timer: ReturnType<typeof setTimeout> | null = null; | |
| 34 | 35 | let closed = false; | |
| 35 | 36 | let opened = false; | |
| 37 | + | // Between asking for a socket ticket and opening the socket (lib/live-socket.ts). | |
| 38 | + | let opening = false; | |
| 36 | 39 | const connect = () => { | |
| 37 | − | if (closed) return; | |
| 40 | + | if (closed || opening) return; | |
| 38 | 41 | 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) => { | |
| 40 | 54 | let ws: WebSocket; | |
| 41 | 55 | try { | |
| 42 | − | ws = new WebSocket(`${scheme}//${location.host}/${slug}/-/chat/live?channel=${encodeURIComponent(channelId)}`); | |
| 56 | + | ws = new WebSocket(address); | |
| 43 | 57 | } catch { | |
| 44 | 58 | schedule(); | |
| 45 | 59 | return; | |
| ⋯ | |||
| 76 | 90 | }; | |
| 77 | 91 | // Back now, not after the wait: the tab is shown, or the network returned. | |
| 78 | 92 | const now = () => { | |
| 79 | − | if (closed || socket.current || document.visibilityState !== "visible") return; | |
| 93 | + | if (closed || opening || socket.current || document.visibilityState !== "visible") return; | |
| 80 | 94 | if (timer) clearTimeout(timer); | |
| 81 | 95 | timer = null; | |
| 82 | 96 | attempt = 0; | |
| 18 | 18 | import * as syncProtocol from "y-protocols/sync"; | |
| 19 | 19 | import * as Y from "yjs"; | |
| 20 | 20 | ||
| 21 | + | import { openLive } from "../../lib/live-socket"; | |
| 21 | 22 | import { heldOpen } from "../../lib/notify-store"; | |
| 22 | 23 | ||
| 23 | 24 | const MESSAGE_SYNC = 0; | |
| ⋯ | |||
| 37 | 38 | private timer: ReturnType<typeof setTimeout> | null = null; | |
| 38 | 39 | private keepalive: ReturnType<typeof setInterval> | null = null; | |
| 39 | 40 | private stopped = false; | |
| 41 | + | /** Between asking for a socket ticket and opening the socket. */ | |
| 42 | + | private opening = false; | |
| 40 | 43 | private readonly statusListeners = new Set<(status: LiveStatus) => void>(); | |
| 41 | 44 | private readonly eventListeners = new Set<(event: FoliosLiveEvent) => void>(); | |
| 42 | 45 | ||
| ⋯ | |||
| 73 | 76 | } | |
| 74 | 77 | ||
| 75 | 78 | private connect() { | |
| 76 | − | if (this.stopped) return; | |
| 79 | + | if (this.stopped || this.opening) return; | |
| 77 | 80 | 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); | |
| 79 | 101 | socket.binaryType = "arraybuffer"; | |
| 80 | 102 | this.socket = socket; | |
| 81 | 103 | socket.onopen = () => { | |
| ⋯ | |||
| 174 | 196 | }; | |
| 175 | 197 | ||
| 176 | 198 | private onOnline = () => { | |
| 177 | − | if (this.socket || this.stopped) return; | |
| 199 | + | if (this.socket || this.opening || this.stopped) return; | |
| 178 | 200 | if (this.timer) clearTimeout(this.timer); | |
| 179 | 201 | this.attempts = 0; | |
| 180 | 202 | this.connect(); | |
| 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 | + | } |
| 19 | 19 | ||
| 20 | 20 | import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry } from "@g1t/contracts"; | |
| 21 | 21 | ||
| 22 | + | import { openLive } from "./live-socket"; | |
| 22 | 23 | import { isIdle, mergePeople } from "./presence"; | |
| 23 | 24 | ||
| 24 | 25 | import { | |
| ⋯ | |||
| 397 | 398 | } | |
| 398 | 399 | } | |
| 399 | 400 | ||
| 401 | + | /** Between asking for a socket ticket and opening the socket (lib/live-socket.ts). */ | |
| 402 | + | let opening = false; | |
| 403 | + | ||
| 400 | 404 | function connect(): void { | |
| 401 | − | if (!running) return; | |
| 405 | + | if (!running || opening) return; | |
| 402 | 406 | 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 { | |
| 405 | 425 | let ws: WebSocket; | |
| 406 | 426 | try { | |
| 407 | − | ws = new WebSocket(`${scheme}//${location.host}/-/live${query}`); | |
| 427 | + | ws = new WebSocket(address); | |
| 408 | 428 | } catch { | |
| 409 | 429 | schedule(); | |
| 410 | 430 | return; | |
| ⋯ | |||
| 442 | 462 | /** Back now: the tab is shown, or the network returned. */ | |
| 443 | 463 | function now(): void { | |
| 444 | 464 | sendState(); | |
| 445 | − | if (!running || socket || document.visibilityState !== "visible") return; | |
| 465 | + | if (!running || opening || socket || document.visibilityState !== "visible") return; | |
| 446 | 466 | if (timer) clearTimeout(timer); | |
| 447 | 467 | timer = null; | |
| 448 | 468 | attempt = 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 72 | 72 | import { RELOADED_KEY, RELOAD_GIVE_UP_MS, clientNavigated, reloadFixes, reloadedBefore } from "./lib/stale-build"; | |
| 73 | 73 | import { useNonce } from "./lib/nonce"; | |
| 74 | 74 | import { isNeedsSignIn } from "./lib/website-token"; | |
| 75 | + | import { setLiveViaToken } from "./lib/live-socket"; | |
| 75 | 76 | import { LiveNotifications } from "./components/notifications/live-notifications"; | |
| 76 | 77 | ||
| 77 | 78 | ||
| ⋯ | |||
| 554 | 555 | if (loaded && inBrowser) lastRoot = loaded; | |
| 555 | 556 | const root = loaded ?? (inBrowser ? lastRoot : undefined); | |
| 556 | 557 | 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)); | |
| 557 | 560 | const { pathname, search } = useLocation(); | |
| 558 | 561 | // Drawn around the error page too: a 404 keeps the sidebar out of a | |
| 559 | 562 | // project or workspace the viewer cannot see. | |
| 56 | 56 | // Live notifications: each tab's feed socket, and the person's | |
| 57 | 57 | // notification settings as JSON (services/notify). | |
| 58 | 58 | 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"), | |
| 59 | 62 | route("-/notify", "routes/notify/api.ts"), | |
| 60 | 63 | route("explore", "routes/explore.tsx", { id: "explore" }), | |
| 61 | 64 | route("pricing", "routes/pricing.tsx"), |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/live"; | |
| 6 | 6 | 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"; | |
| 8 | 9 | ||
| 9 | 10 | /** The longest the counts read for a new socket hold it up. */ | |
| 10 | 11 | const SEED_WAIT_MS = 800; | |
| ⋯ | |||
| 43 | 44 | * hibernating while nothing happens). | |
| 44 | 45 | */ | |
| 45 | 46 | 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); | |
| 47 | 49 | if (!viewer) return new Response("Sign in first.", { status: 401 }); | |
| 48 | 50 | if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { | |
| 49 | 51 | return new Response("This address takes a WebSocket.", { status: 426, headers: { upgrade: "websocket" } }); | |
| 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 | + | } |
| 3 | 3 | import { CHAT_VIEWER_HEADER } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | 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"; | |
| 7 | 8 | ||
| 8 | 9 | /** | |
| 9 | 10 | * A conversation's live socket: `wss://<site>/<workspace>/-/chat/live?channel=<id>`. | |
| ⋯ | |||
| 13 | 14 | * channel, hibernating while nothing happens). | |
| 14 | 15 | */ | |
| 15 | 16 | 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); | |
| 17 | 19 | if (!viewer) return new Response("Sign in to use chat.", { status: 401 }); | |
| 18 | 20 | if (!roleIn(viewer, params.owner)) return new Response("Not found", { status: 404 }); | |
| 19 | 21 | if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { | |
| 3 | 3 | import { DOCS_VIEWER_HEADER } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | 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"; | |
| 7 | 8 | ||
| 8 | 9 | /** | |
| 9 | 10 | * An artifact's live socket: `wss://<site>/<workspace>/-/artifacts/live?folio=<id>`. | |
| ⋯ | |||
| 13 | 14 | * Object per folio, any kind), which enforces that role. | |
| 14 | 15 | */ | |
| 15 | 16 | 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); | |
| 17 | 19 | if (!viewer) return new Response("Sign in to use Artifacts.", { status: 401 }); | |
| 18 | 20 | if (!roleIn(viewer, params.owner)) return new Response("Not found", { status: 404 }); | |
| 19 | 21 | if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { | |
| 58 | 58 | | `WEB_ADDRESS_LIMIT` | 4204 | 3,000 | address | web: every request that reaches the Worker; stops made-up cookies getting round the signed-out limit | | |
| 59 | 59 | | `GIT_ANONYMOUS_LIMIT` | 4205 | 120 | address | web: git smart HTTP without `Authorization` (~40 clones) | | |
| 60 | 60 | | `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 | | |
| 62 | 62 | | `PACK_FILL_LIMIT` | 4301 | 30 | repository id | repos `src/limits.rs`: packs written to `GIT_PACKS`; past it the pack is streamed, not kept | | |
| 63 | 63 | | `ANONYMOUS_FETCH_LIMIT` | 4302 | 120 | repository id | repos: anonymous fetches the git store answers (cache hits never count) | | |
| 64 | 64 | | `API_ANONYMOUS_LIMIT` | 4401 | 60 | `rest:`/`mcp:` + address | api `src/limits.rs`: no token, or a wrong one | | |
| ⋯ | |||
| 103 | 103 | no log line. Error counts in the dashboard still show it, and a recurring | |
| 104 | 104 | one shows up within a few occurrences. To chase a rare error on one of | |
| 105 | 105 | 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. | |