From 407a5ac821288c71531112b959fad11929d90b76 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 17:30:16 -0400 Subject: [PATCH 1/2] fix: tell a teammate's browser whose host it opened instead of reconnecting forever Why: apps/host/src/gateway.ts#function team | a plain GET to /ws without an upgrade is the app asking why its socket was refused; it must get the browser's answer (403 naming the owner for a teammate), never be treated as a peer --- ...ERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md | 6 +++--- ...QCSF4ESXJSWZK588G-every-project-op-that-change.md | 6 +++--- ...084JRSE4KJCVHRDFY-a-plain-get-to-ws-without-an.md | 8 ++++++++ apps/app/src/app.tsx | 9 +++++---- apps/app/src/host.ts | 10 ++++++---- apps/host/src/gateway.ts | 12 +++++++++--- 6 files changed, 34 insertions(+), 17 deletions(-) create mode 100644 .lane/memory/apps/host/src/gateway.ts/01M5JRB6A084JRSE4KJCVHRDFY-a-plain-get-to-ws-without-an.md diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md index 52a633ee..87799eda 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-20T17:04:56Z norm: '1' sig: 1acdc0f6a0895ac5 -body_hash: 018bb7ba4e642584 -raw_hash: 39402f04edba65d3 -vouched: 2026-10-22T21:21:50Z +body_hash: 734539b04f3c9fb5 +raw_hash: faceff1158ed3942 +vouched: 2026-10-22T21:30:21Z lines: 1-1153 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md b/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md index 0fcc2827..cdf92fe1 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-22T14:00:47Z norm: '1' sig: 1acdc0f6a0895ac5 -body_hash: 018bb7ba4e642584 -raw_hash: 39402f04edba65d3 -vouched: 2026-10-22T21:21:50Z +body_hash: 734539b04f3c9fb5 +raw_hash: faceff1158ed3942 +vouched: 2026-10-22T21:30:21Z lines: 1-1309 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5JRB6A084JRSE4KJCVHRDFY-a-plain-get-to-ws-without-an.md b/.lane/memory/apps/host/src/gateway.ts/01M5JRB6A084JRSE4KJCVHRDFY-a-plain-get-to-ws-without-an.md new file mode 100644 index 00000000..181ab131 --- /dev/null +++ b/.lane/memory/apps/host/src/gateway.ts/01M5JRB6A084JRSE4KJCVHRDFY-a-plain-get-to-ws-without-an.md @@ -0,0 +1,8 @@ +--- +id: 01M5JRB6A084JRSE4KJCVHRDFY +anchor: function team +created: 2026-10-22T21:30:16Z +norm: '1' +--- + +a plain GET to /ws without an upgrade is the app asking why its socket was refused; it must get the browser's answer (403 naming the owner for a teammate), never be treated as a peer diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index 7dbfcdac..c7e8aadd 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -126,8 +126,8 @@ function useAnswers(): boolean | undefined { } /** Asked again while disconnected: a helper that was down can come back with a new token. */ -function useRefused(): boolean { - const [value, setValue] = useState(false); +function useRefused(): Awaited> { + const [value, setValue] = useState>>(); useEffect(() => { if (deployed || desktop) return; const check = () => void refused().then(setValue); @@ -140,9 +140,10 @@ function useRefused(): boolean { function Disconnected() { const answers = useAnswers(); - const signedOut = useRefused(); + const refusal = useRefused(); if (desktop) return <>Disconnected from Ace Helper. Open Settings → This Mac to start it.; - if (signedOut) { + if (typeof refusal === "object") return <>{refusal.other}; + if (refusal === "signed-out") { return ( <> This browser isn't signed in to your host. Run ace open{" "} diff --git a/apps/app/src/host.ts b/apps/app/src/host.ts index 5dbcfde7..0f3e64cc 100644 --- a/apps/app/src/host.ts +++ b/apps/app/src/host.ts @@ -32,15 +32,17 @@ window.addEventListener("hashchange", () => { }); /** - * Whether the host refuses this page's credentials, as after Ace Helper restarts with a new token. - * A browser hides a WebSocket's 401, so this asks over HTTP with what the socket sends. + * Why the host refuses this page's socket: its credentials (401), as after Ace Helper restarts + * with a new token, or because it is a teammate's host (403, whose text names its owner). A + * browser hides a WebSocket's status, so this asks over HTTP with what the socket sends. */ -export async function refused(): Promise { +export async function refused(): Promise<"signed-out" | { other: string } | undefined> { const response = await fetch(gateway!.replace(/^ws/, "http"), { cache: "no-store", ...(token ? { headers: { authorization: `Bearer ${token}` } } : {}), }).catch(() => undefined); - return response?.status === 401; + if (response?.status === 401) return "signed-out"; + if (response?.status === 403) return { other: await response.text() }; } // Phones suspend backgrounded pages; reconnect as soon as the person comes back. diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index 279fc1a4..81dbcba6 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -1092,7 +1092,11 @@ function team(machine: Machine, listener: web.Listener, tls?: Bun.TLSOptions): S } return serveApp(url); } - if (origin === null ? listener.secure : !web.origins(machine, listener).has(origin)) { + // A page asks over plain HTTP, without an Origin, why its socket was refused (host.ts). + const socket = request.headers.get("upgrade")?.toLowerCase() === "websocket"; + if ( + origin === null ? socket && listener.secure : !web.origins(machine, listener).has(origin) + ) { return new Response("Forbidden", { status: 403 }); } const ip = server.requestIP(request); @@ -1105,10 +1109,12 @@ function team(machine: Machine, listener: web.Listener, tls?: Bun.TLSOptions): S log("warn", "gateway.whois", { address: ip.address, ...failure(error) }); return new Response("Forbidden", { status: 403 }); } - const browser = origin !== null; + const browser = origin !== null || !socket; if (browser && user !== catalog.user) { log("warn", "gateway.browser.refused", { user, origin }); - return new Response("Open Ace from your own host", { status: 403 }); + return new Response(`This is ${catalog.user}'s host. Open Ace from your own host.`, { + status: 403, + }); } return server.upgrade(request, { data: client(user, !browser, false, from) }) ? undefined From 77b9a010e6dd57e8db4a363d8e0c7fd861674bae Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 17:30:26 -0400 Subject: [PATCH 2/2] lane: sync fix-teammate-browser memory --- ...01M5JRB6A084JRSE4KJCVHRDFY-a-plain-get-to-ws-without-an.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5JRB6A084JRSE4KJCVHRDFY-a-plain-get-to-ws-without-an.md b/.lane/memory/apps/host/src/gateway.ts/01M5JRB6A084JRSE4KJCVHRDFY-a-plain-get-to-ws-without-an.md index 181ab131..68c1b909 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5JRB6A084JRSE4KJCVHRDFY-a-plain-get-to-ws-without-an.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5JRB6A084JRSE4KJCVHRDFY-a-plain-get-to-ws-without-an.md @@ -3,6 +3,10 @@ id: 01M5JRB6A084JRSE4KJCVHRDFY anchor: function team created: 2026-10-22T21:30:16Z norm: '1' +sig: 539e9e824fdbc03b +body_hash: cfca61d6aa3d8217 +raw_hash: 906f43d51bb826b1 +lines: 1077-1125 --- a plain GET to /ws without an upgrade is the app asking why its socket was refused; it must get the browser's answer (403 naming the owner for a teammate), never be treated as a peer