Skip to content

feat(app): ask a teammate's host to join the team over the tailnet - #563

Merged
iamnbutler merged 3 commits into
invite-linkfrom
ask-to-join
Oct 22, 2026
Merged

iamnbutler merged 3 commits into
invite-linkfrom
ask-to-join

Conversation

@iamnbutler

Copy link
Copy Markdown
Contributor

A teammate can join without being sent anything. Their Ace finds a teammate's host on the tailnet that is already in a team and offers " on is in a team. Ask to join". The teammate approves in their own Ace, and the directory URL and secret move host to host over the tailnet. Part of #538 and #550, stacked on #561.

  • Advertise: hello now carries team, the directory's host only (never the secret). Peers already ask every tailnet peer for hello.
  • Offer: the new team-peers op asks each open peer for a fresh hello, so a teammate who joined later shows up. The first-run choice screen and Join your team both list them, refreshing every 5 s.
  • Ask:
    • team-ask (local owner only) sends team-request to that peer and waits.
    • The peer's gateway attaches the asker's Tailscale identity from whois, which now also returns the machine name. It then pushes { asks } to its local app only.
  • Approve:
    • The owner sees "Let join your team?" with Approve and Deny.
    • team-answer is a keychain op, so only the local owner can answer. Approving hands over invite(), and the asking host runs the existing setDirectory: check, then store.
  • Ends: an ask ends with a plain message when it's denied, when the asker disconnects, when they ask again, or after 5 minutes. A host that isn't joined refuses before prompting anyone.

Smoke test

Two source hosts with separate homes and Keychain services, both on the real tailnet listener, plus a local wrangler dev directory.

Exercised end to end:

  • Host A's hello to a tailnet peer socket carried team: 127.0.0.1:8791 and no secret.
  • A peer socket was refused team-answer and team-invite.
  • A team-request over the tailnet socket, with a real whois, made A's app show the prompt. Deny made the asker fail with "They declined…".
  • An asker that disconnected closed the prompt.
  • Approve returned the URL and secret over the tailnet. Host B then joined with them, and its secret was saved in Keychain.

Not exercised: B finding A through team-peers and its UI offer. Peers come from tailscale status, which never lists your own machine, so two hosts on one Mac can't discover each other. I sent the ask over the same kind of tailnet socket that peers.ts opens instead. The 5-minute expiry also wasn't waited out.

Built in Ace

No. Claude Code built this outside Ace while the user was away, from the user's phone request. Ace can't yet run an unattended multi-lane pass; see #5.

A host that isn't in a team finds peers whose hello names a team directory and offers
"<person> on <machine> is in a team. Ask to join". The peer's owner approves or denies in
their app; on approval the directory URL and secret travel over the tailnet and the asking
host joins as with directory-set. Asks end on deny, disconnect, a repeat ask, or after five
minutes.

Why: apps/host/src/protocol.ts#type Hello | hello answers any tailnet peer, so team carries only the directory's host, never the secret
Why: apps/host/src/gateway.ts#function asksChanged | only the local app can answer an ask (team-answer is a keychain op), so asks reach local sockets only
@iamnbutler
iamnbutler merged commit e5f7435 into invite-link Oct 22, 2026
1 check passed
@iamnbutler
iamnbutler deleted the ask-to-join branch October 22, 2026 00:06
iamnbutler added a commit that referenced this pull request Oct 22, 2026
#561)

* feat(app): invite teammates with one link that fills in Join your team

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.

* lane: confirm notes for invite link

* lane: sync invite-link memory

* feat(app): send invites as an https link to the join page

Chat apps linkify https, not ace://. The link opens ace.githubnext.com/join, which offers the download and hands off to ace://join. Pasting either form still fills Join your team.

Why: apps/app/src/invite.ts#function link | the invite must stay in the URL fragment so the secret never reaches the site's server

* lane: confirm invite parse note

* lane: sync invite-link memory

* feat(app): ask a teammate's host to join the team over the tailnet (#563)

* feat(app): ask a teammate's host to join the team over the tailnet

A host that isn't in a team finds peers whose hello names a team directory and offers
"<person> on <machine> is in a team. Ask to join". The peer's owner approves or denies in
their app; on approval the directory URL and secret travel over the tailnet and the asking
host joins as with directory-set. Asks end on deny, disconnect, a repeat ask, or after five
minutes.

Why: apps/host/src/protocol.ts#type Hello | hello answers any tailnet peer, so team carries only the directory's host, never the secret
Why: apps/host/src/gateway.ts#function asksChanged | only the local app can answer an ask (team-answer is a keychain op), so asks reach local sockets only

* lane: confirm notes for ask to join

* lane: sync ask-to-join memory
iamnbutler added a commit that referenced this pull request Oct 22, 2026
… from the app (#553)

* feat(app): offer Get your team started or Join your team on first run

* chore: confirm the app selection note

* fix(app): turn off autocorrect in the team setup fields

* feat(app): deploy the team's Cloudflare services from Get your team started

Ace Helper ships prebuilt directory and channel bundles and deploys them with the
owner's wrangler login, naming them after the tailnet, then joins the directory.
The Set up step shows progress instead of a command to copy. Fly.io still runs
the checkout script.

Why: apps/host/src/team.ts | Ace Helper runs wrangler as plain Bun via BUN_BE_BUN, and deploys the bundles apps/desktop/scripts/helper.ts writes to Resources/services; both layouts must stay in step.

* chore: confirm gateway and helper notes after adding team-deploy

* lane: sync team-onboarding memory

* feat(app): link Deploy to what it creates in the team's Cloudflare account

* feat(app): invite teammates with one link that fills in Join your team (#561)

* feat(app): invite teammates with one link that fills in Join your team

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.

* lane: confirm notes for invite link

* lane: sync invite-link memory

* feat(app): send invites as an https link to the join page

Chat apps linkify https, not ace://. The link opens ace.githubnext.com/join, which offers the download and hands off to ace://join. Pasting either form still fills Join your team.

Why: apps/app/src/invite.ts#function link | the invite must stay in the URL fragment so the secret never reaches the site's server

* lane: confirm invite parse note

* lane: sync invite-link memory

* feat(app): ask a teammate's host to join the team over the tailnet (#563)

* feat(app): ask a teammate's host to join the team over the tailnet

A host that isn't in a team finds peers whose hello names a team directory and offers
"<person> on <machine> is in a team. Ask to join". The peer's owner approves or denies in
their app; on approval the directory URL and secret travel over the tailnet and the asking
host joins as with directory-set. Asks end on deny, disconnect, a repeat ask, or after five
minutes.

Why: apps/host/src/protocol.ts#type Hello | hello answers any tailnet peer, so team carries only the directory's host, never the secret
Why: apps/host/src/gateway.ts#function asksChanged | only the local app can answer an ask (team-answer is a keychain op), so asks reach local sockets only

* lane: confirm notes for ask to join

* lane: sync ask-to-join memory

* lane: confirm gateway note after merging main
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant