diff --git a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md index 03f7b701..ba2cd533 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md +++ b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md @@ -4,9 +4,9 @@ anchor: function App created: 2026-10-15T03:38:04Z norm: '1' sig: 063d0f00fea02511 -body_hash: 0f39cf5b562376a3 -raw_hash: 58afac5d8b57b74a -vouched: 2026-10-21T19:08:54Z +body_hash: 247d8e002cbe5eea +raw_hash: ff8ae1733758ca88 +vouched: 2026-10-22T00:00:58Z lines: 235-824 --- diff --git a/.lane/memory/apps/app/src/invite.ts/01M5G7R116DA7GH3PEGHY3GHDG-an-invite-link-only-fills-th.md b/.lane/memory/apps/app/src/invite.ts/01M5G7R116DA7GH3PEGHY3GHDG-an-invite-link-only-fills-th.md new file mode 100644 index 00000000..e7ba8924 --- /dev/null +++ b/.lane/memory/apps/app/src/invite.ts/01M5G7R116DA7GH3PEGHY3GHDG-an-invite-link-only-fills-th.md @@ -0,0 +1,13 @@ +--- +id: 01M5G7R116DA7GH3PEGHY3GHDG +anchor: function parse +created: 2026-10-21T22:01:42Z +norm: '1' +sig: dbfd6fc52114c42f +body_hash: 5e6861eabfa9b10e +raw_hash: 263fdf4182735ac0 +vouched: 2026-10-21T23:56:16Z +lines: 13-24 +--- + +an invite link only fills the join form; joining must stay an explicit Check and join, since a link can name any directory. diff --git a/.lane/memory/apps/app/src/invite.ts/01M5GE9M9PFM6TQP78EH33REEJ-the-invite-must-stay-in-the.md b/.lane/memory/apps/app/src/invite.ts/01M5GE9M9PFM6TQP78EH33REEJ-the-invite-must-stay-in-the.md new file mode 100644 index 00000000..47c09324 --- /dev/null +++ b/.lane/memory/apps/app/src/invite.ts/01M5GE9M9PFM6TQP78EH33REEJ-the-invite-must-stay-in-the.md @@ -0,0 +1,12 @@ +--- +id: 01M5GE9M9PFM6TQP78EH33REEJ +anchor: function link +created: 2026-10-21T23:56:10Z +norm: '1' +sig: e3ff675f8b8f97f4 +body_hash: d44684f05493d6ed +raw_hash: 5d27cd630e96e30a +lines: 13-15 +--- + +the invite must stay in the URL fragment so the secret never reaches the site's server diff --git a/.lane/memory/apps/desktop/electrobun.config.ts/01M53CZ2S58V1DTJKB64V1SVRD-wkwebview-dictation-needs-ns.md b/.lane/memory/apps/desktop/electrobun.config.ts/01M53CZ2S58V1DTJKB64V1SVRD-wkwebview-dictation-needs-ns.md index a3bdec70..7df6efc8 100644 --- a/.lane/memory/apps/desktop/electrobun.config.ts/01M53CZ2S58V1DTJKB64V1SVRD-wkwebview-dictation-needs-ns.md +++ b/.lane/memory/apps/desktop/electrobun.config.ts/01M53CZ2S58V1DTJKB64V1SVRD-wkwebview-dictation-needs-ns.md @@ -4,8 +4,9 @@ anchor: '@file' created: 2026-10-16T22:22:49Z norm: '1' sig: eb0482a0ddee93a6 -body_hash: de216f5680cfb1c1 -raw_hash: 6c0b5799010be95c +body_hash: 6737492378c42f80 +raw_hash: da3e9e067339f0bf +vouched: 2026-10-21T22:03:14Z lines: 1-42 --- diff --git a/.lane/memory/apps/desktop/electrobun.config.ts/01M5G7R10ME8H8G95ECA7MKHBE-each-build-channel-registers.md b/.lane/memory/apps/desktop/electrobun.config.ts/01M5G7R10ME8H8G95ECA7MKHBE-each-build-channel-registers.md new file mode 100644 index 00000000..f9a12c85 --- /dev/null +++ b/.lane/memory/apps/desktop/electrobun.config.ts/01M5G7R10ME8H8G95ECA7MKHBE-each-build-channel-registers.md @@ -0,0 +1,12 @@ +--- +id: 01M5G7R10ME8H8G95ECA7MKHBE +anchor: '@file' +created: 2026-10-21T22:01:42Z +norm: '1' +sig: eb0482a0ddee93a6 +body_hash: 6737492378c42f80 +raw_hash: da3e9e067339f0bf +lines: 1-45 +--- + +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. diff --git a/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md b/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md index c0dba4ed..072712ea 100644 --- a/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md +++ b/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md index 4c2eb1df..49171b86 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T04:08:49Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 720f5b8ba0cc5afa -raw_hash: f9e13198b0d61950 -vouched: 2026-10-21T19:33:15Z +body_hash: 14495715208898b4 +raw_hash: 2426d3718700367c +vouched: 2026-10-22T00:00:58Z lines: 266-519 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md index 0ae6951c..c843f55c 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T06:00:20Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 720f5b8ba0cc5afa -raw_hash: f9e13198b0d61950 -vouched: 2026-10-21T19:33:15Z +body_hash: 14495715208898b4 +raw_hash: 2426d3718700367c +vouched: 2026-10-22T00:00:58Z lines: 281-561 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md b/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md index 5d9417a6..59596158 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md index 954ec49b..8cef3bb9 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-19T21:09:57Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 720f5b8ba0cc5afa -raw_hash: f9e13198b0d61950 -vouched: 2026-10-21T19:33:15Z +body_hash: 14495715208898b4 +raw_hash: 2426d3718700367c +vouched: 2026-10-22T00:00:58Z lines: 384-700 supersedes: 01M5APK9YCQH467YNMKAC8BT5B --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md index 3802487a..fd333b94 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-20T17:04:56Z norm: '1' sig: 1acdc0f6a0895ac5 -body_hash: e6113f892f35e445 -raw_hash: 01cb24bcaa5e401b -vouched: 2026-10-21T19:33:15Z +body_hash: da1299c8d5811bd8 +raw_hash: fb9d9232096ff061 +vouched: 2026-10-22T00:00:58Z lines: 1-1153 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5GEJ78HX2MSSVN17V2PTKAS-only-the-local-app-can-answe.md b/.lane/memory/apps/host/src/gateway.ts/01M5GEJ78HX2MSSVN17V2PTKAS-only-the-local-app-can-answe.md new file mode 100644 index 00000000..bce083dd --- /dev/null +++ b/.lane/memory/apps/host/src/gateway.ts/01M5GEJ78HX2MSSVN17V2PTKAS-only-the-local-app-can-answe.md @@ -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 diff --git a/.lane/memory/apps/host/src/protocol.ts/01M5GEJ77XBRPQN89DW0CKNAWP-hello-answers-any-tailnet-pe.md b/.lane/memory/apps/host/src/protocol.ts/01M5GEJ77XBRPQN89DW0CKNAWP-hello-answers-any-tailnet-pe.md new file mode 100644 index 00000000..81affd80 --- /dev/null +++ b/.lane/memory/apps/host/src/protocol.ts/01M5GEJ77XBRPQN89DW0CKNAWP-hello-answers-any-tailnet-pe.md @@ -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 diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index df2ede1c..55a94fcf 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"; @@ -55,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"; @@ -300,7 +302,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 +327,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,13 +1067,17 @@ export function App() { )} {(teaming || (!offered && connected && !current)) && ( { setOffered(true); setTeaming(false); }} /> )} + {desktop && window.dispatchEvent(new Event("ace:updates"))} />} 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 && ( + + + + + ); +} diff --git a/apps/app/src/team-setup.tsx b/apps/app/src/team-setup.tsx index 920c6854..91e9aefe 100644 --- a/apps/app/src/team-setup.tsx +++ b/apps/app/src/team-setup.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useId, useState } from "react"; +import { type ReactNode, useEffect, useId, useState } from "react"; import { Button, @@ -13,9 +13,11 @@ 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"; +import { type Invite as Team, link, page, parse } from "./invite"; const DOCS = "https://github.com/githubnext/ace/blob/main/docs/setup.md"; const PROVIDERS = { @@ -38,19 +40,34 @@ const STEPS: Partial> = { /** * First-run setup for the first person on a tailnet, or a way into the team for everyone after. * Membership stays the tailnet's: this only decides where the team's channels are listed. + * `invite` opens on joining with an invite link's values; `inviting` opens on this team's invite. */ -export function TeamSetup({ github, onClose }: { github?: string; onClose: () => void }) { - const [step, setStep] = useState("fork"); +export function TeamSetup({ github, invite, inviting, onClose }: { + github?: string; + invite?: Team; + inviting?: boolean; + onClose: () => void; +}) { + const [step, setStep] = useState(invite ? "join" : inviting ? "invite" : "fork"); const [starting, setStarting] = useState(true); const [listed, setListed] = useState<"hosted" | "peers">("hosted"); const [provider, setProvider] = useState("cloudflare"); const [hosted, setHosted] = useState(false); - const [joined, setJoined] = useState<{ url: string; secret: string }>(); + const [joined, setJoined] = useState(); + const [error, setError] = useState(""); + + useEffect(() => { + if (!inviting) return; + host.request({ op: "team-invite" }).then( + setJoined, + (error: Error) => setError(error.message), + ); + }, [inviting]); return ( next || onClose()}> - {STEPS[step] && } + {STEPS[step] && !inviting && } {step === "fork" && ( <> @@ -58,6 +75,7 @@ export function TeamSetup({ github, onClose }: { github?: string; 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." /> +
onSelect={() => setStarting(false)} title="Join your team" > - Someone already set Ace up. Use the directory URL and team secret they sent you. + Someone already set Ace up. Use the invite link they sent you.