Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
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
9 changes: 5 additions & 4 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof refused>> {
const [value, setValue] = useState<Awaited<ReturnType<typeof refused>>>();
useEffect(() => {
if (deployed || desktop) return;
const check = () => void refused().then(setValue);
Expand All @@ -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 <code>ace open</code>{" "}
Expand Down
10 changes: 6 additions & 4 deletions apps/app/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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.
Expand Down
12 changes: 9 additions & 3 deletions apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down