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: 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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
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: 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
---

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: 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
---

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: 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
---
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: 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
---

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
34 changes: 32 additions & 2 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -300,7 +302,7 @@ export function App() {
const [pins, setPins] = useLocalStorage<string[]>("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<boolean | "inviting" | Invite>(false);
const [collapsed, setCollapsed] = useState<Record<SessionSidebarGroupId, boolean>>({
pinned: false,
mine: false,
Expand All @@ -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<string>).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(() => {
Expand Down Expand Up @@ -1041,13 +1067,17 @@ export function App() {
)}
{(teaming || (!offered && connected && !current)) && (
<TeamSetup
key={typeof teaming === "object" ? teaming.url : String(teaming)}
github={hello.github}
invite={typeof teaming === "object" ? teaming : undefined}
inviting={teaming === "inviting"}
onClose={() => {
setOffered(true);
setTeaming(false);
}}
/>
)}
<TeamAsks />
{desktop && <UpdateNotice onOpen={() => window.dispatchEvent(new Event("ace:updates"))} />}
<Toaster />
</TooltipProvider>
Expand Down
20 changes: 17 additions & 3 deletions apps/app/src/directory-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
}}
/>
<Input
type="password"
Expand Down Expand Up @@ -232,6 +237,15 @@ function TeamDirectory(
: "Syncing…"}
</Value>
)}
{directory.url && saved && !editing && (
<Button
variant="outline"
disabled={!!action.busy}
onClick={() => window.dispatchEvent(new Event("ace:team-invite"))}
>
Invite…
</Button>
)}
{!editing && (
<Button
variant="outline"
Expand Down
35 changes: 35 additions & 0 deletions apps/app/src/invite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* 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.
*/
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
* off to stable's `ace://join`. The invite rides in the fragment, which browsers never send.
*/
export const page = "https://ace.githubnext.com/join";

export function link({ url, secret }: Invite): string {
return `${page}#${new URLSearchParams({ directory: url, secret })}`;
}

/** Reads the page's link, or a handoff in any build's scheme (ace, ace-canary, ace-dev). */
export function parse(text: string): Invite | undefined {
let value: URL;
try {
value = new URL(text.trim());
} catch {
return;
}
const params = `${value.origin}${value.pathname}`.replace(/\/$/, "") === page
? new URLSearchParams(value.hash.slice(1))
: /^ace(-[a-z]+)?:$/.test(value.protocol) && value.host === "join"
? value.searchParams
: undefined;
const url = params?.get("directory");
const secret = params?.get("secret");
if (url && secret) return { url, secret };
}
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>
);
}
Loading