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