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: function App
created: 2026-10-15T03:38:04Z
norm: '1'
sig: 063d0f00fea02511
body_hash: a558d5cc2db9572f
raw_hash: ff829592063e519f
vouched: 2026-10-21T22:03:14Z
body_hash: 247d8e002cbe5eea
raw_hash: ff8ae1733758ca88
vouched: 2026-10-22T00:00:58Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class GatewayClient
created: 2026-10-15T02:55:20Z
norm: '1'
sig: 41366b9e88f0e890
body_hash: 667acf3cf8d2b033
raw_hash: 4affce51e3e1735f
vouched: 2026-10-21T19:33:15Z
body_hash: 0ea1b5a225ceb8b0
raw_hash: 99b659f96ee9516e
vouched: 2026-10-22T00:00:58Z
lines: 33-240
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-15T04:08:49Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: 5950b974bd224a8d
raw_hash: ce8b5ae9fa8408ba
vouched: 2026-10-21T22:03:14Z
body_hash: 14495715208898b4
raw_hash: 2426d3718700367c
vouched: 2026-10-22T00:00:58Z
lines: 266-519
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-15T06:00:20Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: 5950b974bd224a8d
raw_hash: ce8b5ae9fa8408ba
vouched: 2026-10-21T22:03:14Z
body_hash: 14495715208898b4
raw_hash: 2426d3718700367c
vouched: 2026-10-22T00:00:58Z
lines: 281-561
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function websocket
created: 2026-10-15T20:16:37Z
norm: '1'
sig: 1b462e5d3bc33728
body_hash: 39bbd535e37f47a0
raw_hash: c1a585dfb617245a
vouched: 2026-10-20T17:10:51Z
body_hash: e4839023c586ff4f
raw_hash: cda22d4980cf5a80
vouched: 2026-10-22T00:00:58Z
lines: 673-737
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-19T21:09:57Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: 5950b974bd224a8d
raw_hash: ce8b5ae9fa8408ba
vouched: 2026-10-21T22:03:14Z
body_hash: 14495715208898b4
raw_hash: 2426d3718700367c
vouched: 2026-10-22T00:00:58Z
lines: 384-700
supersedes: 01M5APK9YCQH467YNMKAC8BT5B
---
Expand Down
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: 95b019291fdd466c
raw_hash: 561d1e864a3cd088
vouched: 2026-10-21T22:03:14Z
body_hash: da1299c8d5811bd8
raw_hash: fb9d9232096ff061
vouched: 2026-10-22T00:00:58Z
lines: 1-1153
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5GEJ78HX2MSSVN17V2PTKAS
anchor: function asksChanged
created: 2026-10-22T00:00:52Z
norm: '1'
sig: 89d3d187f4a8cef0
body_hash: 10d18a209d9cd21c
raw_hash: a2a5da6ab853d6c9
lines: 391-394
---

only the local app can answer an ask (team-answer is a keychain op), so asks reach local sockets only
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5GEJ77XBRPQN89DW0CKNAWP
anchor: type Hello
created: 2026-10-22T00:00:52Z
norm: '1'
sig: a2275d05c454362d
body_hash: e3b0c44298fc1c14
raw_hash: 7276efa4e6f4c00c
lines: 157-157
---

hello answers any tailnet peer, so team carries only the directory's host, never the secret
2 changes: 2 additions & 0 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { type AppProject, label, projects, title } from "./projects";
import { type Section, Settings } from "./settings";
import { SetupProject } from "./setup-project";
import { ShareProject, useCanShare } from "./share-project";
import { TeamAsks } from "./team-asks";
import { TeamSetup } from "./team-setup";
import { UpdateNotice } from "./updates";
import { useWindowStorage } from "./window-storage";
Expand Down Expand Up @@ -1076,6 +1077,7 @@ export function App() {
}}
/>
)}
<TeamAsks />
{desktop && <UpdateNotice onOpen={() => window.dispatchEvent(new Event("ace:updates"))} />}
<Toaster />
</TooltipProvider>
Expand Down
4 changes: 3 additions & 1 deletion apps/app/src/invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
* An invite link carries the team's directory URL and secret in one string a teammate can click or
* paste. Opening one only fills in the join form; the person still checks and joins.
*/
export type Invite = { url: string; secret: string };
import type { Invite } from "@ace/host/protocol";

export type { Invite };

/**
* An https page, because chat apps linkify it and not `ace://`; it offers the download, then hands
Expand Down
62 changes: 62 additions & 0 deletions apps/app/src/team-asks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useState, useSyncExternalStore } from "react";

import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ace/ui";

import { host } from "./host";

/**
* Someone on the tailnet asked to join this host's team. Approving sends them the directory URL
* and team secret; closing leaves the ask to expire.
*/
export function TeamAsks() {
const asks = useSyncExternalStore(host.subscribe, () => host.asks);
const [dismissed, setDismissed] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const ask = asks.find((value) => !dismissed.includes(value.id));
if (!ask) return null;
const who = ask.github || ask.login;

async function answer(approve: boolean) {
setBusy(true);
setError("");
try {
await host.request({ op: "team-answer", ask: ask!.id, approve });
} catch (error) {
setError((error as Error).message);
} finally {
setBusy(false);
}
}

return (
<Dialog open onOpenChange={(next) => next || setDismissed([...dismissed, ask.id])}>
<DialogContent className="gap-4 sm:max-w-[28rem]">
<DialogHeader>
<DialogTitle>Let {who} join your team?</DialogTitle>
<DialogDescription>
{ask.login} on {ask.host}{" "}
asked from Ace. Approving sends this host's directory URL and team secret to {ask.host}
{" "}
over your tailnet.
</DialogDescription>
</DialogHeader>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
<DialogFooter>
<Button variant="ghost" disabled={busy} onClick={() => void answer(false)}>
Deny
</Button>
<Button disabled={busy} onClick={() => void answer(true)}>Approve</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
77 changes: 77 additions & 0 deletions apps/app/src/team-setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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."
/>
<Nearby onJoined={onClose} />
<div role="radiogroup" className="flex flex-col gap-2">
<Choice
selected={starting}
Expand Down Expand Up @@ -107,6 +109,7 @@ export function TeamSetup({ github, invite, inviting, onClose }: {
? "Check the directory before you join: it lists your channels for everyone who has this link."
: "Paste the invite link your teammate sent you, or enter the directory URL and team secret."}
/>
{!invite && <Nearby onJoined={onClose} />}
<Join initial={invite} onJoined={onClose} back={() => setStep("fork")} />
</>
)}
Expand Down Expand Up @@ -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<TeamPeer[]>([]);
useEffect(() => {
let live = true;
const load = () =>
host.request<TeamPeer[]>({ 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<TeamPeer>();
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 (
<div className="flex flex-col gap-2">
{peers.map((peer) => (
<div
key={peer.host}
className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm"
>
<span className="min-w-0">
<span className="font-medium">{peer.github || peer.login}</span>
<span className="text-muted-foreground">
{" "}on {peer.host} is in a team.
</span>
</span>
<Button
size="sm"
variant="outline"
disabled={!!asking}
onClick={() => void ask(peer)}
>
{asking === peer && <IconLoader className="size-3.5 animate-spin" aria-hidden />}
{asking === peer ? "Waiting…" : "Ask to join"}
</Button>
</div>
))}
{asking && (
<p className="text-xs text-muted-foreground">
{asking.github || asking.login} approves it in their Ace. You join as soon as they do.
</p>
)}
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
</div>
);
}

/** Today's Settings → Team join: the directory checks the secret before either is saved. */
function Join({ initial, onJoined, back }: {
initial?: Team;
Expand Down
80 changes: 80 additions & 0 deletions apps/host/src/asks.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout>;
};

const waiting = new Map<string, Waiting>();

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<Invite> {
// 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<void> {
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"));
}
}
Loading