+ {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