From 342b79f37ed66e7b8fb56e191b1259a801cc9c8c Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 21 Oct 2026 18:01:42 -0400 Subject: [PATCH 1/7] feat(app): invite teammates with one link that fills in Join your team MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Invite screen and Settings → Team → Invite… copy an ace://join link carrying the directory URL and team secret. Pasting it into either join form fills both fields, and the desktop app registers its channel's scheme so opening the link brings up Join your team, filled in, for the person to check and join. Why: apps/desktop/electrobun.config.ts | each build channel registers its own scheme (ace, ace-canary, ace-dev) so a dev or Canary build never answers stable's invite links; apps/app/src/invite.ts reads all of them. Why: apps/app/src/invite.ts#function parse | an invite link only fills the join form; joining must stay an explicit Check and join, since a link can name any directory. --- apps/app/src/app.tsx | 32 ++++++- apps/app/src/directory-settings.tsx | 20 +++- apps/app/src/invite.ts | 24 +++++ apps/app/src/team-setup.tsx | 139 +++++++++++++++++++--------- apps/desktop/electrobun.config.ts | 3 + apps/desktop/src/index.ts | 21 ++++- apps/host/src/gateway.ts | 4 + apps/host/src/protocol.ts | 2 + apps/host/src/settings.ts | 8 ++ 9 files changed, 205 insertions(+), 48 deletions(-) create mode 100644 apps/app/src/invite.ts diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index df2ede1c..1ee849c8 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -44,6 +44,7 @@ import { Github, GithubSidebar } from "./github"; import { useGithubRefresh } from "./github-cache"; import { desktop, here, titlebar } from "./desktop"; import { host } from "./host"; +import { type Invite, parse } from "./invite"; import { Rename } from "./layout/rename"; import { MoveChannel } from "./move-channel"; import { Navigation, type Page, WindowControls } from "./navigation"; @@ -300,7 +301,7 @@ export function App() { const [pins, setPins] = useLocalStorage("ace:pinned-channels", []); // Offered once on first run; Settings → Team opens it again. const [offered, setOffered] = useLocalStorage("ace:team-setup", false); - const [teaming, setTeaming] = useState(false); + const [teaming, setTeaming] = useState(false); const [collapsed, setCollapsed] = useState>({ pinned: false, mine: false, @@ -325,8 +326,32 @@ export function App() { setSettings(false); setTeaming(true); }; + const invite = () => { + setSettings(false); + setTeaming("inviting"); + }; + // The desktop app hands over an invite link it was opened with, as an event or, for a new + // window, in the hash. + const join = (link: string) => { + const invite = parse(link); + if (!invite) return; + setSettings(false); + setTeaming(invite); + }; + const joining = (event: Event) => join((event as CustomEvent).detail); window.addEventListener("ace:team-setup", open); - return () => window.removeEventListener("ace:team-setup", open); + window.addEventListener("ace:team-invite", invite); + window.addEventListener("ace:join", joining); + const fragment = new URLSearchParams(location.hash.slice(1)).get("join"); + if (fragment) { + history.replaceState(null, "", location.pathname + location.search); + join(fragment); + } + return () => { + window.removeEventListener("ace:team-setup", open); + window.removeEventListener("ace:team-invite", invite); + window.removeEventListener("ace:join", joining); + }; }, []); useEffect(() => { @@ -1041,7 +1066,10 @@ export function App() { )} {(teaming || (!offered && connected && !current)) && ( { setOffered(true); setTeaming(false); diff --git a/apps/app/src/directory-settings.tsx b/apps/app/src/directory-settings.tsx index dd9e792c..ae75b25b 100644 --- a/apps/app/src/directory-settings.tsx +++ b/apps/app/src/directory-settings.tsx @@ -5,6 +5,7 @@ import type { Diagnostics } from "@ace/host/protocol"; import { host } from "./host"; import { useDiagnostics } from "./host-settings"; +import { parse } from "./invite"; import { Confirm, Group, Page, Problem, Row, Status, Value } from "./settings-layout"; const secrets = { @@ -165,11 +166,15 @@ function TeamDirectory( autoFocus type="url" spellCheck={false} - aria-label="Directory URL" - placeholder="https://ace-directory.example.workers.dev" + aria-label="Invite link or directory URL" + placeholder="Invite link or https://ace-directory.example.workers.dev" value={url} disabled={!!action.busy} - onChange={(event) => setUrl(event.target.value)} + onChange={(event) => { + const invite = parse(event.target.value); + setUrl(invite?.url || event.target.value); + if (invite) setSecret(invite.secret); + }} /> )} + {directory.url && saved && !editing && ( + + )} {!editing && ( } /> )} @@ -380,13 +385,58 @@ function Deploy({ hosted, command, back, onJoined }: { ); } +/** The invite link, with its two values for teammates whose chat won't open an `ace://` link. */ +function Members({ team }: { team: Team }) { + const [separate, setSeparate] = useState(false); + return ( + <> + +
  • + Download Ace from ace.githubnext.com +
  • +
  • + Get on your tailnet:{" "} + invite them in Tailscale +
  • +
  • + Open the invite link, or paste it into Join your team +
    + + {separate && ( + <> + + + + )} +
    + +
  • +
    +

    + Send it privately.{" "} + The link holds the team secret: anyone with it can read every channel's name and summary and + post in hosted channels. +

    + + ); +} + /** Today's Settings → Team join: the directory checks the secret before either is saved. */ -function Join( - { onJoined, back }: { onJoined: (url: string, secret: string) => void; back: () => void }, -) { +function Join({ initial, onJoined, back }: { + initial?: Team; + onJoined: (url: string, secret: string) => void; + back: () => void; +}) { const id = useId(); - const [url, setUrl] = useState(""); - const [secret, setSecret] = useState(""); + const [url, setUrl] = useState(initial?.url || ""); + const [secret, setSecret] = useState(initial?.secret || ""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -416,11 +466,15 @@ function Join( setUrl(event.target.value)} + onChange={(event) => { + const invite = parse(event.target.value); + setUrl(invite?.url || event.target.value); + if (invite) setSecret(invite.secret); + }} /> - {secret ? "•".repeat(24) : value} + {secret ? "•".repeat(24) : label || value} + + + + + ); +} diff --git a/apps/app/src/team-setup.tsx b/apps/app/src/team-setup.tsx index 874f5300..91e9aefe 100644 --- a/apps/app/src/team-setup.tsx +++ b/apps/app/src/team-setup.tsx @@ -13,6 +13,7 @@ import { Switch, } from "@ace/ui"; import { IconArrowUpRight, IconCheck, IconCopy, IconLoader } from "@ace/ui/icons"; +import type { TeamPeer } from "@ace/host/protocol"; import { host } from "./host"; import { useDiagnostics } from "./host-settings"; @@ -74,6 +75,7 @@ export function TeamSetup({ github, invite, inviting, onClose }: { title="Set up Ace for your team" description="Everyone on your tailnet is your team. Start with how they'll find each other's channels." /> +
    + {!invite && } setStep("fork")} /> )} @@ -431,6 +434,80 @@ function Members({ team }: { team: Team }) { ); } +/** Peers' hosts already in a team; asked again while open, since a teammate may come online. */ +function useTeamPeers(): TeamPeer[] { + const [peers, setPeers] = useState([]); + useEffect(() => { + let live = true; + const load = () => + host.request({ op: "team-peers" }).then( + (value) => live && setPeers(value), + () => {}, + ); + void load(); + const timer = setInterval(load, 5000); + return () => { + live = false; + clearInterval(timer); + }; + }, []); + return peers; +} + +/** Ask a teammate's host to let this one in; their owner approves in their Ace. */ +function Nearby({ onJoined }: { onJoined: () => void }) { + const peers = useTeamPeers(); + const [asking, setAsking] = useState(); + const [error, setError] = useState(""); + if (!peers.length) return null; + + async function ask(peer: TeamPeer) { + setAsking(peer); + setError(""); + try { + await host.request({ op: "team-ask", host: peer.host }); + onJoined(); + } catch (error) { + setError((error as Error).message); + } finally { + setAsking(undefined); + } + } + + return ( +
    + {peers.map((peer) => ( +
    + + {peer.github || peer.login} + + {" "}on {peer.host} is in a team. + + + +
    + ))} + {asking && ( +

    + {asking.github || asking.login} approves it in their Ace. You join as soon as they do. +

    + )} + {error &&

    {error}

    } +
    + ); +} + /** Today's Settings → Team join: the directory checks the secret before either is saved. */ function Join({ initial, onJoined, back }: { initial?: Team; diff --git a/apps/host/src/asks.ts b/apps/host/src/asks.ts new file mode 100644 index 00000000..8f20d1c8 --- /dev/null +++ b/apps/host/src/asks.ts @@ -0,0 +1,80 @@ +/** + * Asking to join over the tailnet. A host that isn't in a team asks a teammate's host, and that + * host's owner approves in their app; the directory URL and secret then go to the asking host over + * WireGuard, so nobody sends them by hand. Tailscale names who is asking, and only this host's + * owner, from its local app, can approve. + */ +import type { Invite, TeamAsk } from "./protocol"; +import { invite } from "./settings"; + +/** Long enough to notice the prompt; an unanswered ask then fails instead of waiting forever. */ +const EXPIRES = 5 * 60_000; + +type Waiting = { + ask: TeamAsk; + /** The asking peer's socket, so an ask ends with its connection. */ + from: object; + resolve: (value: Invite) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +const waiting = new Map(); + +export function list(): TeamAsk[] { + return [...waiting.values()].map(({ ask }) => ask); +} + +function end(id: string, changed: () => void) { + const entry = waiting.get(id); + if (!entry) return; + clearTimeout(entry.timer); + waiting.delete(id); + changed(); + return entry; +} + +/** Waits for the owner's answer; asking again from the same machine replaces the earlier ask. */ +export async function ask( + from: object, + person: { login: string; host: string; github?: string }, + changed: () => void, +): Promise { + // Refuse before prompting anyone when there is nothing to hand over. + await invite(); + for (const { ask } of waiting.values()) { + if (ask.login === person.login && ask.host === person.host) { + end(ask.id, changed)?.reject(new Error("Asked again")); + } + } + const id = crypto.randomUUID(); + const expires = Date.now() + EXPIRES; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + end(id, changed)?.reject( + new Error("Nobody answered in time. Ask again when they're around."), + ); + }, EXPIRES); + waiting.set(id, { ask: { id, ...person, expires }, from, resolve, reject, timer }); + changed(); + }); +} + +export async function answer(id: string, approve: boolean, changed: () => void): Promise { + const entry = waiting.get(id); + if (!entry) throw new Error("That request has ended"); + if (!approve) { + end(id, changed); + return entry.reject(new Error("They declined. Ask them for an invite link instead.")); + } + const team = await invite(); + end(id, changed); + entry.resolve(team); +} + +/** A peer that disconnects can't receive an approval. */ +export function drop(from: object, changed: () => void): void { + for (const entry of waiting.values()) { + if (entry.from === from) end(entry.ask.id, changed)?.reject(new Error("Disconnected")); + } +} diff --git a/apps/host/src/gateway-client.ts b/apps/host/src/gateway-client.ts index 43fc7485..74bd2ce2 100644 --- a/apps/host/src/gateway-client.ts +++ b/apps/host/src/gateway-client.ts @@ -11,6 +11,7 @@ import type { Project, Request, TabRename, + TeamAsk, TerminalFrame, WindowState, } from "./protocol"; @@ -61,6 +62,8 @@ export class GatewayClient { channels: Listing[] = []; projects: Project[] = []; people: People = {}; + /** Asks to join the team waiting for this host's owner; only the local app receives them. */ + asks: TeamAsk[] = []; /** Everyone here by project ID, while the host is connected. */ presence: Record = {}; settingsVersion = 0; @@ -137,6 +140,10 @@ export class GatewayClient { this.projects = frame.projects; return this.#emit(); } + if ("asks" in frame) { + this.asks = frame.asks; + return this.#emit(); + } if ("people" in frame) { this.people = frame.people; return this.#emit(); diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index 9d9a7669..fb7263d1 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -12,6 +12,7 @@ import * as compute from "./compute"; import { config } from "./config"; import * as demo from "./demo"; import { diagnostics } from "./diagnostics"; +import * as asks from "./asks"; import * as directory from "./directory"; import { retryLoginShell, shell } from "./environment"; import type { GatewayClient, Watch } from "./gateway-client"; @@ -73,6 +74,8 @@ type Client = { user: string; /** Arrived over the tailnet. Peers act only on this host's channels, and never as its owner. */ peer: boolean; + /** The tailnet machine a peer connected from, as Tailscale named it. */ + machine?: string; local: boolean; channels: Map>; /** This client's connections to peer hosts, opened when it first uses one of their channels. */ @@ -384,6 +387,12 @@ function projectsChanged(): Promise { }); } +/** Only the local app can answer an ask, so only it is told about them. */ +function asksChanged(): void { + const frame = JSON.stringify({ asks: asks.list() } satisfies HostFrame); + for (const socket of sockets) if (socket.data.local) socket.send(frame); +} + function settingsChanged(): void { for (const socket of sockets) { if (!socket.data.peer) socket.send(JSON.stringify({ settings: true } satisfies HostFrame)); @@ -451,6 +460,8 @@ const keychainOps = new Set([ "directory-set", "team-deploy", "team-invite", + "team-ask", + "team-answer", ]); const localOps = new Set([ "projects", @@ -469,6 +480,7 @@ const localOps = new Set([ "key-check", "key-remove", "preferences", + "team-peers", "compute", "folder", "compute-start", @@ -575,8 +587,15 @@ async function handle( updating = false; } return; - case "hello": - return { user: client.user, host: name, github: github.login(loginChanged) }; + case "hello": { + const team = directory.url(); + return { + user: client.user, + host: name, + github: github.login(loginChanged), + ...(team ? { team: new URL(team).host } : {}), + }; + } case "project-access": return access.describe(request.project); case "project-share": @@ -669,6 +688,20 @@ async function handle( return deployTeam(request.hosted, (waiting) => send({ id, waiting })); case "team-invite": return invite(); + case "team-peers": + return peers.teams(); + case "team-ask": { + const team = await peers.ask(request.host); + return setDirectory(team.url, team.secret); + } + case "team-request": { + if (!client.peer || !client.machine) throw new Error("Ask from your own host"); + const asker = { login: client.user, host: client.machine }; + const login = people()[client.user]; + return asks.ask(socket, login ? { ...asker, github: login } : asker, asksChanged); + } + case "team-answer": + return asks.answer(request.ask, request.approve, asksChanged); case "models": if (request.host && request.host !== name && !client.peer) { return (await remote(client, request.host)).request({ op: "models" }); @@ -852,6 +885,9 @@ function websocket(): Bun.WebSocketHandler { socket.send(JSON.stringify({ projects: await projects.list() } satisfies HostFrame)); socket.send(JSON.stringify({ people: people() } satisfies HostFrame)); socket.send(JSON.stringify({ presence: present() } satisfies HostFrame)); + if (socket.data.local) { + socket.send(JSON.stringify({ asks: asks.list() } satisfies HostFrame)); + } }); }, async message(socket, raw) { @@ -892,6 +928,7 @@ function websocket(): Bun.WebSocketHandler { close(socket, code) { sockets.delete(socket); windows.drop(socket); + if (socket.data.peer) asks.drop(socket, asksChanged); if (!socket.data.peer) lobbyChanged(); log("info", "gateway.close", { user: socket.data.user, peer: socket.data.peer, code }); for (const open of socket.data.channels.values()) { @@ -903,8 +940,16 @@ function websocket(): Bun.WebSocketHandler { }; } -function client(user: string, peer: boolean, local = false): Client { - return { user, peer, local, channels: new Map(), remotes: new Map(), terminals: new Map() }; +function client(user: string, peer: boolean, local = false, machine?: string): Client { + return { + user, + peer, + local, + machine, + channels: new Map(), + remotes: new Map(), + terminals: new Map(), + }; } async function serveApp(url: URL): Promise { @@ -997,8 +1042,9 @@ function team(machine: Machine, listener: web.Listener, tls?: Bun.TLSOptions): S const ip = server.requestIP(request); if (!ip) return new Response("Forbidden", { status: 403 }); let user: string; + let from: string; try { - user = await whois(ip.address, ip.port); + ({ login: user, host: from } = await whois(ip.address, ip.port)); } catch (error) { log("warn", "gateway.whois", { address: ip.address, ...failure(error) }); return new Response("Forbidden", { status: 403 }); @@ -1008,7 +1054,7 @@ function team(machine: Machine, listener: web.Listener, tls?: Bun.TLSOptions): S log("warn", "gateway.browser.refused", { user, origin }); return new Response("Open Ace from your own host", { status: 403 }); } - return server.upgrade(request, { data: client(user, !browser) }) + return server.upgrade(request, { data: client(user, !browser, false, from) }) ? undefined : new Response("Upgrade failed", { status: 400 }); }, diff --git a/apps/host/src/peers.ts b/apps/host/src/peers.ts index fff546fa..fa34a912 100644 --- a/apps/host/src/peers.ts +++ b/apps/host/src/peers.ts @@ -4,7 +4,7 @@ import { isIP } from "node:net"; import { config } from "./config"; import { GatewayClient } from "./gateway-client"; import { log } from "./log"; -import type { Hello, Listing, People } from "./protocol"; +import type { Hello, Invite, Listing, People, TeamPeer } from "./protocol"; import { type Machine, peers } from "./tailnet"; /** Hosts serve their tailnet listener on the same port unless told otherwise. */ @@ -139,6 +139,32 @@ export function find(channel: string): Machine | undefined { } } +/** Peers whose hosts are in a team, asked now: a peer may have joined since it first answered. */ +export async function teams(): Promise { + const found = await Promise.all( + [...hosts.values()].map(async ({ machine, client }) => { + if (client.status !== "open" || stalled.has(machine.name)) return; + const hello = await Promise.race([ + client.request({ op: "hello" }), + Bun.sleep(3000), + ]).catch(() => undefined); + if (!hello?.team) return; + const peer: TeamPeer = { host: machine.name, login: machine.login, team: hello.team }; + const github = hello.github || named[machine.login]; + if (github) peer.github = github; + return peer; + }), + ); + return found.filter((peer) => !!peer); +} + +/** Waits for the peer's owner to approve; see asks.ts. */ +export function ask(name: string): Promise { + const client = hosts.get(name)?.client; + if (client?.status !== "open") throw new Error(`${name} isn't reachable on the tailnet`); + return client.request({ op: "team-request" }); +} + export function machine(name: string): Machine | undefined { return hosts.get(name)?.machine; } diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index 2155511f..91ce86d8 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -153,7 +153,17 @@ export function isInactive( } /** `github` is the host owner's GitHub login, when its GitHub CLI is signed in. */ -export type Hello = { user: string; host: string; github?: string }; +/** `team` is the host of the directory this host joined, never its secret, so peers can ask to join. */ +export type Hello = { user: string; host: string; github?: string; team?: string }; + +/** What joining a team takes: its directory URL and the team secret. */ +export type Invite = { url: string; secret: string }; + +/** A peer whose host is in a team, which this host can ask to join. */ +export type TeamPeer = { host: string; login: string; github?: string; team: string }; + +/** Someone on the tailnet asking this host's owner to let them into the team. */ +export type TeamAsk = { id: string; login: string; host: string; github?: string; expires: number }; /** GitHub logins by participant (Tailscale login), from this host, its peers, and the directory. */ export type People = Record; @@ -309,6 +319,16 @@ export type HostRequest = | { op: "team-deploy"; hosted: boolean } /** The joined directory's URL and the saved team secret, for the owner to invite a teammate. */ | { op: "team-invite" } + /** Peers whose hosts are in a team, for a host that isn't in one yet. */ + | { op: "team-peers" } + /** + * Ask a peer's owner to let this host into their team; on approval it answers with the + * directory and secret over the tailnet, and this host joins as with `directory-set`. + */ + | { op: "team-ask"; host: string } + /** Sent by an asking peer host. Answers with the directory URL and secret once the owner approves. */ + | { op: "team-request" } + | { op: "team-answer"; ask: string; approve: boolean } /** Quiesce this helper before replacing its application, or recover a canceled update. */ | { op: "update-prepare" } | { op: "update-cancel" } @@ -374,6 +394,8 @@ export type HostFrame = | { settings: true } /** Opened projects belong to the local owner and are never sent to tailnet peers. */ | { projects: Project[] } + /** Asks to join the team that wait for the owner, sent to the local app only. */ + | { asks: TeamAsk[] } /** Sent to local clients when known GitHub logins change. */ | { people: People } /** diff --git a/apps/host/src/settings.ts b/apps/host/src/settings.ts index 8e5eb9a2..cf9bc96f 100644 --- a/apps/host/src/settings.ts +++ b/apps/host/src/settings.ts @@ -7,7 +7,7 @@ import { forget, key, KEYCHAIN_WAITING, latest, overriding, settled, store } fro import { models, parseModel } from "./manage"; import { canonical } from "./move"; import { isModel, preference, saveCompute, saveFolder, savePreference } from "./preferences"; -import type { Compute, ProviderId, Settings } from "./protocol"; +import type { Compute, Invite, ProviderId, Settings } from "./protocol"; const providers = new Map( [ @@ -176,7 +176,7 @@ export async function forgetKey(name: string): Promise { } /** What a teammate needs to join: this host's directory and the secret it was joined with. */ -export async function invite(): Promise<{ url: string; secret: string }> { +export async function invite(): Promise { const url = directory.url(); const secret = await key("ACE_SECRET"); if (!url || !secret) throw new Error("Join the team's directory first"); diff --git a/apps/host/src/tailnet.ts b/apps/host/src/tailnet.ts index 83c11353..db0813e4 100644 --- a/apps/host/src/tailnet.ts +++ b/apps/host/src/tailnet.ts @@ -139,15 +139,18 @@ export async function certificate( return { cert, key }; } -/** The person behind an inbound TCP connection, as Tailscale verified it. Tagged nodes are refused. */ -export async function whois(ip: string, port: number): Promise { +/** + * The person and machine behind an inbound TCP connection, as Tailscale verified them. Tagged + * nodes are refused. + */ +export async function whois(ip: string, port: number): Promise<{ login: string; host: string }> { const address = tailnet(ip); if (!address) throw new Error("Not a tailnet address"); const endpoint = isIP(address) === 6 ? `[${address}]:${port}` : `${address}:${port}`; const value = await run(["whois", "--json", "--proto=tcp", endpoint]) as { - Node: { Tags?: string[] }; + Node: { Name: string; Tags?: string[] }; UserProfile: { LoginName: string }; }; if (value.Node.Tags?.length) throw new Error("Tagged nodes cannot participate"); - return value.UserProfile.LoginName; + return { login: value.UserProfile.LoginName, host: value.Node.Name.split(".")[0] || address }; }