summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md29
-rw-r--r--docs/USERGUIDE.md7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js49
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-link.js95
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/tests/harness/group_link_probe.py176
-rw-r--r--packages/meshbay-hub/tests/test_group_link.py170
-rw-r--r--packages/meshbay-hub/tests/test_group_link_flow.py67
20 files changed, 650 insertions, 12 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 78a9cb5..7c5dce9 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -2088,10 +2088,17 @@ A group's **identity is its UUID**, everywhere: the route, the node's configurat
membership. A group **name is unique per owner account**, case-insensitively and
trimmed, enforced by a functional unique index; two different owners may each have
a `photos`. Names are displayed as `name@owner`, which is a label plus a create-time
-check and **not an addressing scheme**. The handle is hub-local: the same
+check and **not an identity**. The handle is hub-local: the same
`name@owner` on two federated hubs are different groups, and a federated row shows
its source hub rather than an account.
+The client also accepts the handle in the address, as an alias for the UUID
+(§8.4): `#/name@owner`, optionally followed by a path inside the group. It is
+resolved **in the client, against the account's own `/v1/groups/mine`**, and no
+hub route answers "which group is called this" — so a handle tells nobody
+anything they could not already see, and cannot be used to probe for a group.
+A rename breaks the handle links to a group and none of its `#/group/<id>` ones.
+
`visibility` and `join_policy` are the two independent axes described in §3.5.
`join_policy` is read from the node's own configuration, never from the hub.
@@ -2545,6 +2552,26 @@ that decides where the hub is or fetches the API relative to the page origin.
That is a testable invariant, and it is what any feature adding third-party egress
must preserve — which is one of the reasons enrichment is node-side (§6.5).
+**Inside the application, every route is a fragment** (`#/…`). What follows `#`
+is never sent to a server, so it is in no hub or proxy log and no `Referer`;
+that is what lets an invitation carry its code (§3.4), and it is why the same
+router runs unchanged on `app://meshbay` and in the Android WebView, where no
+server could answer a path. Two forms name a group:
+
+| Route | Meaning |
+|---|---|
+| `#/group/<uuid>` | the group — every link the application draws |
+| `#/name@owner` | the same group by its handle (§7.3); the address shows this form while a group is open, written with `replace` so it is not a history entry |
+| `#/name@owner/<root>/<dir>/<file>` | a file: Files opens on its folder and the file is downloaded. A folder instead of a file opens Files there. The path is taken out of the address once acted on, so a reload does not download twice |
+
+The owner is after the **last** `@` (a username cannot contain one); each path
+segment is percent-decoded on its own. Opened signed out, the sign-in form
+stands in for the page and the address is left alone, so signing in lands on
+it. A download started this way has no user gesture behind it, so where a browser
+offers a Save As dialog it takes the fallback a dialog refused for want of a
+gesture already takes (`file-utils.js` `_openDownloadTarget`): streamed to the
+download folder. `static/group-link.js`.
+
### 8.5 Downloads and streaming
**Downloads go to disk, never through RAM, on every platform.** There are three
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index 97ea679..d7f0624 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -297,6 +297,13 @@ file browser — sort, select, download, preview.
- **Right-click a file or folder** for the same actions as the toolbar, listing
only the ones that apply to it. On a ticked row the menu acts on everything
ticked, like the toolbar does.
+- **A link to a group, a folder or a file.** While a group is open the address
+ bar shows `https://<hub>/#/name@owner` — the name under the group's title.
+ Add a path after it to point inside the group:
+ `#/name@owner/root/folder/photo.jpg` downloads that file, and a folder opens
+ Files there. Only members get anywhere with such a link — anyone else is told
+ the group is unknown — and someone not signed in is asked to sign in first,
+ then taken where the link pointed. Renaming the group breaks these links.
### Chat
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index c5f2733..bdab271 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -21,6 +21,7 @@ import {
} from './hub-client.js';
import { startIdleWatch, markActive } from './idle.js';
import { GroupPage } from './group-page.js';
+import { parseGroupLink, groupLinkRoute, findLinkedGroup } from './group-link.js';
import { lazy } from './lazy.js';
import { ConnectionPool } from './connection-pool.js';
import { MusicPlayerBar } from './music-player.js';
@@ -766,6 +767,9 @@ function App() {
const [needsHub, setNeedsHub] = useState(
platform.isNative && !platform.hubBase());
const [groups, setGroups] = useState([]);
+ // Whether `/mine` has answered for this sign-in. A `#/name@owner` link can
+ // only be called unknown once the list it is looked up in has arrived.
+ const [groupsLoaded, setGroupsLoaded] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
@@ -1008,11 +1012,13 @@ function App() {
if (!user) {
nodeKeyAskedForRef.current = null;
setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false);
+ setGroupsLoaded(false);
return;
}
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
- .catch(() => setGroups([]));
+ .catch(() => setGroups([]))
+ .finally(() => setGroupsLoaded(true));
hubFetch('/v1/users/me/preferences', { token: user.token })
.then(async (prefs) => {
setUserPrefs(prefs || {});
@@ -1233,6 +1239,31 @@ function App() {
// out as the only way back.
const refreshAuth = useCallback(() => refreshAccessToken(), []);
+ // A group is reached by its id (`#/group/<id>`, what every link inside the
+ // application uses) or by its handle (`#/name@owner[/path]`, the one a person
+ // types or shares — group-link.js). Both open the same page; `groupRoute` is
+ // the first form whichever was used, so the sidebar and the page see one.
+ const groupLink = route.startsWith('/group/') ? null : parseGroupLink(route);
+ const linkedGroup = groupLink ? findLinkedGroup(groups, groupLink) : null;
+ const groupId = route.startsWith('/group/') ? route.slice(7)
+ : linkedGroup ? linkedGroup.id : null;
+ const groupRoute = groupId ? '/group/' + groupId : route;
+ const shownGroup = groupId ? groups.find(g => g.id === groupId) : null;
+ const linkPath = groupLink && linkedGroup ? groupLink.path : '';
+ // The address shows the handle, which is the link worth copying. `replace`,
+ // so this is not a history entry; and only once the path a link named has
+ // been acted on (`onLinkOpened`), so a reload does not download the file a
+ // second time.
+ const shownGroupRoute = shownGroup ? groupLinkRoute(shownGroup) : null;
+ useEffect(() => {
+ if (shownGroupRoute && !linkPath && route !== shownGroupRoute) {
+ window.location.replace('#' + shownGroupRoute);
+ }
+ }, [route, shownGroupRoute, linkPath]);
+ const onLinkOpened = useCallback(() => {
+ if (shownGroupRoute) window.location.replace('#' + shownGroupRoute);
+ }, [shownGroupRoute]);
+
let page;
// A desktop build with no hub configured cannot do anything at all, so it
// asks before showing a sign-in form that could not work. The field comes
@@ -1282,17 +1313,23 @@ function App() {
}} />`;
} else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) {
page = html`<${LazyNodePage} groups=${groups} token=${user.token} username=${user.username} />`;
- } else if (route.startsWith('/group/')) {
- const groupId = route.slice(7);
- const group = groups.find(g => g.id === groupId);
+ } else if (groupId) {
page = html`<${GroupPage}
- groupId=${groupId} group=${group} token=${user.token}
+ groupId=${groupId} group=${shownGroup} token=${user.token}
+ openPath=${linkPath} onLinkOpened=${onLinkOpened}
username=${user.username} userId=${user.userId}
userPrefs=${userPrefs}
onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications}
onGroupUpdated=${updateGroup} onPresence=${notePresence}
onLeft=${handleLeftGroup}
onPlayQueue=${handlePlayQueue} onStopMusic=${handleStopMusic} />`;
+ } else if (groupLink) {
+ // Not among this account's groups — or not yet known to be. The same words
+ // whether it does not exist or is someone else's: the list it was looked
+ // up in is this account's own, so there is nothing else to tell.
+ page = groupsLoaded
+ ? html`<div class="page-content"><p class="page-message">${t('group.link_unknown')}</p></div>`
+ : html`<div class="page-content"><p class="page-message"><span class="spinner"></span></p></div>`;
} else if (route === '/admin') {
page = (user.role === 'moderator' || user.role === 'admin')
? html`<${LazyAdminPage} token=${user.token} role=${user.role} />`
@@ -1334,7 +1371,7 @@ function App() {
groups=${groups}
presence=${presence}
indexProgressPct=${indexProgressPct}
- route=${route}
+ route=${groupRoute}
menuOpen=${menuOpen}
role=${user.role}
allowPublicGroups=${allowPublicGroups}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index c12db72..57db475 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -185,8 +185,12 @@ export function LoginPage({ onLogin }) {
// Trimmed to match the hub's stored username and every client-side key
// derivation (auth_key, bundle_key, recovery_key all fold the username in).
await onLogin(name, password);
- // Back to the invitation that sent them here, if one is waiting.
- navigate(loadPending() ? '/invite' : '/');
+ // Back to the invitation that sent them here, if one is waiting. Otherwise
+ // nowhere: on `#/login` or `#/register` the router sends a signed-in
+ // person home itself, and anywhere else this form stood in for the page
+ // the address names — a group, a file in one — which is where they were
+ // going. Sending everyone home lost every link opened signed out.
+ if (loadPending()) navigate('/invite');
} catch (err) {
if (err.message === 'email_verification_required') {
setPendingVerif(true);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index 38157b9..978090c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -142,12 +142,16 @@ function FilesPanel({
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, userId, setError, onPreview,
showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, onReport,
+ openDirectory,
}) {
const [selected, setSelected] = useState(() => new Set());
const [sortKey, setSortKey] = useState('name');
const [sortAsc, setSortAsc] = useState(true);
const [filter, setFilter] = useState('');
- const [currentPath, setCurrentPath] = useState('');
+ const [currentPath, setCurrentPath] = useState(() => (openDirectory ? openDirectory.dir : ''));
+ // A link into the group named a folder (group-page.js); a new object each
+ // time, so the same folder linked twice is opened twice.
+ useEffect(() => { if (openDirectory) setCurrentPath(openDirectory.dir); }, [openDirectory]);
const [refreshing, setRefreshing] = useState(false);
// The toolbar pins below the page's own band and tells the column heads how
// far down to pin. Its height is not a constant — it wraps to three rows on
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-link.js b/packages/meshbay-hub/src/meshbay_hub/static/group-link.js
new file mode 100644
index 0000000..3c9063c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-link.js
@@ -0,0 +1,95 @@
+/**
+ * A group named in the address: `#/name@owner`, optionally followed by a path
+ * inside it — `#/name@owner/root/folder/file.jpg`.
+ *
+ * The same handle `GroupName` shows under every group, so a link reads the way
+ * the group is labelled. It is resolved against the signed-in account's own
+ * group list and nothing else: no hub route answers "which group is this
+ * name", so a name says nothing to someone who is not already a member, and
+ * nobody can probe for one. The UUID stays the group's identity; a renamed
+ * group breaks its name links, never its `#/group/<id>` ones.
+ *
+ * In the fragment, never the path: what follows `#` is not sent to the server,
+ * so a group's name and a file's path appear in no hub or proxy log and in no
+ * Referer.
+ *
+ * The owner is everything after the *last* `@`: a username cannot contain one
+ * (users.py, RegisterRequest), a group name can. Each segment is
+ * percent-decoded on its own, so a `/` inside a name or a file name travels as
+ * `%2F` without splitting the path.
+ *
+ * No imports, so `test_group_link.py` can execute the module as it is.
+ */
+
+function _decode(segment) {
+ try { return decodeURIComponent(segment); } catch { return null; }
+}
+
+// `@` is legal in a fragment and the owner is found by the last one, so the
+// name's own need no escaping; everything else is escaped as a URI component.
+function _encode(segment) {
+ return encodeURIComponent(segment).replace(/%40/g, '@');
+}
+
+/**
+ * `{ name, owner, path }` for a route (the hash without its `#`), or null when
+ * the route does not name a group. `path` is '' for the group itself.
+ */
+function parseGroupLink(route) {
+ if (!route || route[0] !== '/') return null;
+ const parts = route.slice(1).split('/');
+ const head = _decode(parts[0]);
+ if (!head) return null;
+ const at = head.lastIndexOf('@');
+ if (at <= 0 || at === head.length - 1) return null;
+ const rest = parts.slice(1).filter(Boolean).map(_decode);
+ if (rest.some((s) => s === null || s === '.' || s === '..')) return null;
+ return { name: head.slice(0, at), owner: head.slice(at + 1), path: rest.join('/') };
+}
+
+/** The route naming `group`, and `path` inside it when one is given. */
+function groupLinkRoute(group, path = '') {
+ const head = `/${_encode(group.name)}@${_encode(group.owner_username || '')}`;
+ const tail = path ? '/' + path.split('/').filter(Boolean).map(_encode).join('/') : '';
+ return head + tail;
+}
+
+/**
+ * The group `link` names among `groups` (`/v1/groups/mine` rows), or null.
+ *
+ * Case-insensitively on the name, as the hub keeps it unique
+ * (`uq_groups_owner_name` is on `lower(name)`); the owner as typed first, then
+ * case-insensitively when that finds exactly one.
+ */
+function findLinkedGroup(groups, link) {
+ if (!link) return null;
+ const name = link.name.toLowerCase();
+ const named = (groups || []).filter((g) => (g.name || '').toLowerCase() === name);
+ const exact = named.find((g) => g.owner_username === link.owner);
+ if (exact) return exact;
+ const owner = link.owner.toLowerCase();
+ const loose = named.filter((g) => (g.owner_username || '').toLowerCase() === owner);
+ return loose.length === 1 ? loose[0] : null;
+}
+
+/**
+ * What `path` names in a group's index: `{ kind: 'file', entry }`,
+ * `{ kind: 'dir', dir }`, or null. An entry's `path` is its folder, root
+ * first, and `name` its file name — so a file is matched on both together.
+ * Folders come from the files under them and from the node's own listing,
+ * which is the only place an empty one appears.
+ */
+function resolveLinkedPath(entries, nodeDirs, path) {
+ if (!path) return null;
+ const cut = path.lastIndexOf('/');
+ const dir = cut < 0 ? '' : path.slice(0, cut);
+ const name = cut < 0 ? path : path.slice(cut + 1);
+ const entry = (entries || []).find((e) => (e.path || '') === dir && e.name === name);
+ if (entry) return { kind: 'file', entry };
+ const prefix = path + '/';
+ const isDir = (nodeDirs || []).includes(path)
+ || (entries || []).some((e) => (e.path || '') === path || (e.path || '').startsWith(prefix));
+ return isDir ? { kind: 'dir', dir: path } : null;
+}
+
+export { parseGroupLink, groupLinkRoute, findLinkedGroup, resolveLinkedPath };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index fe858b2..a2d6c18 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -5,6 +5,7 @@ import { t } from './i18n.js';
import { Icon } from './icon.js';
import { transfers } from './transfers.js';
import { downloadEntry } from './file-utils.js';
+import { resolveLinkedPath } from './group-link.js';
import {
HUB, session, hubFetch, ensureFreshToken,
_loadBundleKey, _loadRecoveryKey, _storeBundleKey,
@@ -33,7 +34,8 @@ import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js';
*/
function GroupPage({ groupId, group, token, username, userId, userPrefs,
onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft,
- onPlayQueue: parentOnPlayQueue, onStopMusic }) {
+ onPlayQueue: parentOnPlayQueue, onStopMusic,
+ openPath = '', onLinkOpened }) {
const [status, setStatus] = useState('idle');
// The tab bar pins under the navigation bar and tells the application's own
// toolbar how far down to pin (style.css, "Sticky chrome").
@@ -313,12 +315,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
});
}, []);
+ // Which group `entries` is the index of. The page is not remounted between
+ // groups, so for one render after a switch `status` and `entries` are still
+ // the last group's — and a link into the new one must not be looked up there.
+ const indexGroupRef = useRef(null);
+
// One place that takes an index from the node and puts it everywhere it has to
// go. Deleting a file used to refresh the table and leave the cache alone, so
// the search page went on offering a file that no longer existed until the
// group was reconnected.
const applyIndex = useCallback((indexMsg) => {
const fresh = indexMsg.entries || [];
+ indexGroupRef.current = groupId;
setEntries(fresh);
if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
if (indexMsg.roots) setNodeRoots(indexMsg.roots);
@@ -731,6 +739,28 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
await downloadEntry(transfers, transport, gekRef.current, entry);
}, []);
+ // A link that named a path inside the group (`#/name@owner/root/dir/file`,
+ // group-link.js): a file is downloaded, and Files opens on its folder either
+ // way. Acted on once the index is in — before that, nothing can say whether
+ // the path is a file, a folder or nothing — and then handed back to the
+ // router, which takes the path out of the address so a reload does not
+ // download it again.
+ const [openDirectory, setOpenDirectory] = useState(null);
+ useEffect(() => { setOpenDirectory(null); }, [groupId]);
+ useEffect(() => {
+ if (!openPath || status !== 'connected' || indexGroupRef.current !== groupId) return;
+ const target = resolveLinkedPath(entries, nodeDirs, openPath);
+ if (!target) {
+ setError(t('group.link_path_unknown', { path: openPath }));
+ } else {
+ const dir = target.kind === 'file' ? (target.entry.path || '') : target.dir;
+ setOpenDirectory({ dir });
+ chooseTab('files');
+ if (target.kind === 'file') downloadFileForModal(target.entry);
+ }
+ if (onLinkOpened) onLinkOpened();
+ }, [openPath, status, groupId]);
+
const refreshIndex = useCallback(async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
@@ -987,7 +1017,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
</div>
${apps.map(a => tab === a.key && html`
- <${a.Component} key=${a.key + '-' + groupId} ...${commonProps} />
+ <${a.Component} key=${a.key + '-' + groupId} ...${commonProps}
+ ...${a.key === 'files' ? { openDirectory } : {}} />
`)}
${tab === 'settings' && html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 5cc9968..8d14dca 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -210,6 +210,8 @@ export default {
'group.mkdir_prompt': 'Name des neuen Ordners',
'group.mkdir_offline': 'Nicht mit dem Node verbunden.',
'group.download_offline': 'Keine Verbindung zum Node — der Download kann nicht starten. Die Verbindung wird automatisch wiederhergestellt; versuchen Sie es gleich erneut.',
+ 'group.link_unknown': "Dieser Link verweist auf eine Gruppe, in der Sie nicht Mitglied sind, oder die nicht mehr so heißt.",
+ 'group.link_path_unknown': "Unter {path} gibt es in dieser Gruppe nichts — die Datei wurde vielleicht verschoben, umbenannt oder gelöscht.",
'group.download_write_stalled': 'Die Datei wird nicht mehr auf die Festplatte geschrieben ({seconds} s ohne Fortschritt). Der Download wurde abgebrochen statt hängen gelassen; versuchen Sie es erneut.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 5aa53b7..a6cd0b4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -210,6 +210,8 @@ export default {
'group.mkdir_prompt': 'New folder name',
'group.mkdir_offline': 'Not connected to the node.',
'group.download_offline': 'Not connected to the node — the download cannot start. It reconnects on its own; try again in a moment.',
+ 'group.link_unknown': "This link names a group you are not a member of, or one that no longer goes by that name.",
+ 'group.link_path_unknown': "Nothing at {path} in this group — the file may have been moved, renamed or deleted.",
'group.download_write_stalled': 'The file stopped being written to disk ({seconds}s with no progress). The download was stopped rather than left hanging; try it again.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 6558a46..e066081 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -208,6 +208,8 @@ export default {
'group.mkdir_prompt': 'Nombre de la nueva carpeta',
'group.mkdir_offline': 'Sin conexión con el nodo.',
'group.download_offline': 'Sin conexión con el nodo — la descarga no puede empezar. Se reconecta sola; inténtelo de nuevo en un momento.',
+ 'group.link_unknown': "Este enlace nombra un grupo del que no eres miembro, o que ya no se llama así.",
+ 'group.link_path_unknown': "No hay nada en {path} en este grupo: puede que el archivo se haya movido, renombrado o eliminado.",
'group.download_write_stalled': 'El archivo dejó de escribirse en el disco ({seconds} s sin avance). La descarga se detuvo en lugar de quedarse colgada; inténtelo de nuevo.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 5e4c0bf..01ccfcd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -209,6 +209,8 @@ export default {
'group.mkdir_prompt': 'Nom du nouveau dossier',
'group.mkdir_offline': 'Non connecté au nœud.',
'group.download_offline': 'Pas de connexion au node — le téléchargement ne peut pas démarrer. La reconnexion est automatique, réessayez dans un instant.',
+ 'group.link_unknown': "Ce lien désigne un groupe dont vous n'êtes pas membre, ou qui ne porte plus ce nom.",
+ 'group.link_path_unknown': "Rien à l'emplacement {path} dans ce groupe — le fichier a peut-être été déplacé, renommé ou supprimé.",
'group.download_write_stalled': 'L\'écriture du fichier sur le disque s\'est arrêtée ({seconds} s sans progression). Le téléchargement a été interrompu plutôt que laissé en suspens ; réessayez.',
'device.add_title': 'Ce navigateur n’est pas encore lié à ce nœud',
'device.add_hint': 'Votre compte est connu ici, mais ce navigateur détient une autre clé. Approuvez-le depuis un appareil déjà lié — sans passer par l’opérateur.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 8772376..71af9f8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -209,6 +209,8 @@ export default {
'group.mkdir_prompt': 'Nome della nuova cartella',
'group.mkdir_offline': 'Non connesso al nodo.',
'group.download_offline': 'Nessuna connessione al nodo — il download non può iniziare. La riconnessione è automatica, riprovi tra poco.',
+ 'group.link_unknown': "Questo link indica un gruppo di cui non sei membro, o che non ha più questo nome.",
+ 'group.link_path_unknown': "Non c'è nulla in {path} in questo gruppo: il file potrebbe essere stato spostato, rinominato o eliminato.",
'group.download_write_stalled': 'Il file ha smesso di essere scritto su disco ({seconds} s senza progressi). Il download è stato interrotto invece di restare bloccato; riprovi.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index fa34470..4b357c8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -207,6 +207,8 @@ export default {
'group.mkdir_prompt': '新しいフォルダー名',
'group.mkdir_offline': 'ノードに接続していません。',
'group.download_offline': 'ノードに接続していません — ダウンロードを開始できません。再接続は自動で行われます。少し待って再試行してください。',
+ 'group.link_unknown': "このリンクは、あなたがメンバーでないグループ、またはもうその名前ではないグループを指しています。",
+ 'group.link_path_unknown': "このグループの {path} には何もありません。ファイルが移動、名前変更、または削除された可能性があります。",
'group.download_write_stalled': 'ファイルのディスクへの書き込みが止まりました({seconds} 秒間進みません)。ぶら下がったままにせず中止しました。もう一度お試しください。',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index fa0841d..1f4b12c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -210,6 +210,8 @@ export default {
'group.mkdir_prompt': 'Naam van de nieuwe map',
'group.mkdir_offline': 'Niet verbonden met de node.',
'group.download_offline': 'Geen verbinding met de node — de download kan niet starten. Er wordt automatisch opnieuw verbonden; probeer het zo weer.',
+ 'group.link_unknown': "Deze link verwijst naar een groep waarvan je geen lid bent, of die niet meer zo heet.",
+ 'group.link_path_unknown': "Er staat niets op {path} in deze groep — het bestand is misschien verplaatst, hernoemd of verwijderd.",
'group.download_write_stalled': 'Het bestand wordt niet meer naar schijf geschreven ({seconds} s zonder voortgang). De download is gestopt in plaats van te blijven hangen; probeer het opnieuw.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 9de29ea..05a3006 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -213,6 +213,8 @@ export default {
'group.mkdir_prompt': 'Nazwa nowego folderu',
'group.mkdir_offline': 'Brak połączenia z węzłem.',
'group.download_offline': 'Brak połączenia z węzłem — pobieranie nie może się rozpocząć. Połączenie wróci samo; proszę spróbować za chwilę.',
+ 'group.link_unknown': "Ten link wskazuje grupę, do której nie należysz, lub która nie nosi już tej nazwy.",
+ 'group.link_path_unknown': "W tej grupie nie ma niczego pod {path} — plik mógł zostać przeniesiony, zmieniony lub usunięty.",
'group.download_write_stalled': 'Plik przestał być zapisywany na dysk ({seconds} s bez postępu). Pobieranie zostało przerwane, zamiast wisieć w nieskończoność; proszę spróbować ponownie.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 908a8db..824c69a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -210,6 +210,8 @@ export default {
'group.mkdir_prompt': 'Nome da nova pasta',
'group.mkdir_offline': 'Sem conexão com o nó.',
'group.download_offline': 'Sem conexão com o nó — o download não pode começar. Ele reconecta sozinho; tente de novo em instantes.',
+ 'group.link_unknown': "Este link aponta para um grupo do qual você não é membro, ou que não tem mais esse nome.",
+ 'group.link_path_unknown': "Não há nada em {path} neste grupo — o arquivo pode ter sido movido, renomeado ou excluído.",
'group.download_write_stalled': 'O arquivo parou de ser gravado no disco ({seconds}s sem progresso). O download foi interrompido em vez de ficar travado; tente de novo.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index d6fe782..d0d3954 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -206,6 +206,8 @@ export default {
'group.mkdir_prompt': '新文件夹名称',
'group.mkdir_offline': '未连接到节点。',
'group.download_offline': '未连接到节点 — 无法开始下载。连接会自动恢复,请稍后重试。',
+ 'group.link_unknown': "此链接指向一个您不是其成员的群组,或该群组已不再使用此名称。",
+ 'group.link_path_unknown': "此群组中 {path} 处没有内容——文件可能已被移动、重命名或删除。",
'group.download_write_stalled': '文件停止写入磁盘({seconds} 秒无进展)。已中止下载而不是让它一直卡住,请重试。',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/tests/harness/group_link_probe.py b/packages/meshbay-hub/tests/harness/group_link_probe.py
new file mode 100644
index 0000000..2d7c0d7
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/group_link_probe.py
@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+"""
+A group link, `#/name@owner[/path]`, opened in the real application.
+
+`test_group_link.py` runs `group-link.js` on its own; this is where it meets
+the router, the account's group list and the sign-in state. Loads the shipped
+`app.js` in a real browser with `fetch` stubbed (no node answers, so a group
+page goes as far as "offline"), once per case:
+
+ handle — `#/demo@someowner`: the group page, the address left as it is
+ uuid — `#/group/<id>`: the same page, the address showing the handle
+ file — `#/demo@someowner/<path>`: the page, the path kept in the address
+ until the index can say what it is (no node here: never)
+ unknown — `#/demo@stranger1`: not among the account's groups
+ signed_out — `#/demo@someowner/<path>` with no session: the sign-in form, and
+ the address untouched under it
+
+ group_link_probe.py
+
+Prints JSON: one object per case.
+"""
+
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8774
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e"
+FILE = "backup/city_2015/backup/IMG_0001.JPG"
+LINKS = {
+ "handle": "#/demo@someowner",
+ "uuid": f"#/group/{GROUP}",
+ "file": f"#/demo@someowner/{FILE}",
+ "unknown": "#/demo@stranger1",
+ "signed_out": f"#/demo@someowner/{FILE}",
+}
+CASES = list(LINKS)
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head><body>
+<div id="app"></div>
+<script type="module">
+const CASE = new URLSearchParams(location.search).get('case');
+const LINKS = __LINKS__;
+const realFetch = window.fetch.bind(window);
+const post = (o) => realFetch('/log', { method: 'POST', body: JSON.stringify(o) });
+const calls = [];
+const json = (body, status = 200) => ({
+ ok: status < 400, status, statusText: '', headers: new Headers(),
+ json: async () => body, text: async () => JSON.stringify(body),
+});
+window.fetch = async (url, init = {}) => {
+ const u = String(url);
+ calls.push(u);
+ if (u.includes('/v1/users/me/preferences')) return json({});
+ if (u.includes('/v1/users/me')) return json({ user_id: 'u-1', role: 'user' });
+ if (u.includes('/v1/groups/mine')) return json({ groups: [
+ { id: '__GROUP__', name: 'demo', owner_username: 'someowner', visibility: 'private',
+ created_at: '2026-01-01T00:00:00+00:00', description: '' },
+ { id: 'other-group', name: 'demo', owner_username: 'otherowner', visibility: 'private',
+ created_at: '2026-01-01T00:00:00+00:00', description: '' },
+ ] });
+ if (u.includes('/nodes')) return json({ nodes: [] });
+ return json({});
+};
+if (CASE === 'signed_out') {
+ localStorage.removeItem('mb_auth');
+} else {
+ localStorage.setItem('mb_auth', JSON.stringify({
+ username: 'member-account', userId: 'u-1', token: 'tok', refreshToken: 'ref',
+ role: 'user' }));
+}
+sessionStorage.clear();
+history.replaceState(null, '', '/?case=' + CASE + LINKS[CASE]);
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+(async () => {
+ const out = { case: CASE };
+ try {
+ await import('/app.js');
+ await wait(2000);
+ out.hash = decodeURI(location.hash);
+ out.history_length = history.length;
+ out.group_page = Boolean(document.querySelector('.group-header'));
+ out.group_title = (document.querySelector('.group-header h2') || {}).innerText || '';
+ out.active_sidebar = [...document.querySelectorAll('.sidebar-group.active')]
+ .map((a) => a.getAttribute('href'));
+ out.message = (document.querySelector('main .page-message') || {}).innerText || '';
+ out.spinner = Boolean(document.querySelector('main .page-message .spinner'));
+ out.login_form = Boolean(document.querySelector('input[type=password]'));
+ out.hub_calls = calls.map((c) => c.replace(/^https?:\/\/[^/]+/, ''));
+ } catch (e) {
+ out.error = String(e && e.stack || e);
+ }
+ post(out);
+})();
+</script></body></html>
+""".replace("__GROUP__", GROUP).replace("__LINKS__", json.dumps(LINKS))
+
+
+class H(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ body = self.rfile.read(length)
+ if self.path == "/log":
+ RECORDS.append(json.loads(body.decode()))
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+ if path == "/":
+ self._send(PAGE.encode(), "text/html; charset=utf-8")
+ return
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else (
+ "application/wasm" if asset.suffix == ".wasm" else "application/octet-stream")
+ self._send(asset.read_bytes(), ctype)
+
+
+def _run(case: str) -> dict | None:
+ before = len(RECORDS)
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", f"http://127.0.0.1:{PORT}/?case={case}"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(300):
+ if len(RECORDS) > before:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ return RECORDS[before] if len(RECORDS) > before else None
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ results = [_run(case) for case in CASES]
+ if not all(results):
+ print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr)
+ return 1
+ print(json.dumps(results, indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_group_link.py b/packages/meshbay-hub/tests/test_group_link.py
new file mode 100644
index 0000000..e71df20
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_group_link.py
@@ -0,0 +1,170 @@
+"""
+A group named in the address: `#/name@owner[/path]`.
+
+The handle under every group's name is also a link to it, and a path after it
+names a folder to open or a file to download. `group-link.js` parses it,
+builds it, finds the group among the account's own and the entry in the
+group's index; the module is executed whole, as `test_search_source_merge.py`
+does with `source-merge.js`, so these rules are the ones the page runs.
+
+Also held here, at source level: the sign-in form no longer sends everyone
+home, which is what made any link opened signed out land on the home page.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+SRC = STATIC / "group-link.js"
+
+IMPORT = re.compile(r"^\s*import\b", re.M)
+EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M)
+
+needs_node = pytest.mark.skipif(
+ shutil.which("node") is None or not SRC.exists(),
+ reason="node or the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def module_source():
+ text = SRC.read_text(encoding="utf-8")
+ assert not IMPORT.search(text), (
+ "group-link.js has gained an import; this test runs it standalone")
+ stripped, n = EXPORT.subn("", text)
+ assert n == 1
+ return stripped
+
+
+def _run(tmp_path, module_source, expr):
+ script = tmp_path / "case.js"
+ script.write_text(f"{module_source}\nconsole.log(JSON.stringify({expr}));\n",
+ encoding="utf-8")
+ out = subprocess.run(["node", str(script)], capture_output=True, text=True,
+ encoding="utf-8", timeout=30)
+ assert out.returncode == 0, out.stderr
+ return json.loads(out.stdout)
+
+
+GROUPS = [
+ {"id": "g1", "name": "demo", "owner_username": "someowner"},
+ {"id": "g2", "name": "demo", "owner_username": "otherowner"},
+ {"id": "g3", "name": "trips@home", "owner_username": "someowner"},
+ {"id": "g4", "name": "a/b c", "owner_username": "someowner"},
+]
+
+ENTRIES = [
+ {"path": "backup/city_2015/backup", "name": "IMG_0001.JPG"},
+ {"path": "backup/city_2015", "name": "notes.txt"},
+ {"path": "music", "name": "track 01.flac"},
+]
+
+
+@needs_node
+@pytest.mark.parametrize("route, expected", [
+ ("/demo@someowner", {"name": "demo", "owner": "someowner", "path": ""}),
+ ("/demo@someowner/backup/city_2015/backup/IMG_0001.JPG",
+ {"name": "demo", "owner": "someowner",
+ "path": "backup/city_2015/backup/IMG_0001.JPG"}),
+ # The owner is after the last `@`: a group name may hold one, a username not.
+ ("/trips@home@someowner", {"name": "trips@home", "owner": "someowner", "path": ""}),
+ # Each segment decoded on its own: an escaped `/` stays inside its segment.
+ ("/a%2Fb%20c@someowner/music/track%2001.flac",
+ {"name": "a/b c", "owner": "someowner", "path": "music/track 01.flac"}),
+ ("/demo@someowner/music/", {"name": "demo", "owner": "someowner", "path": "music"}),
+ # Every other route, and anything that is not a well-formed handle.
+ ("/group/0f8fad5b-d9cb-469f-a165-70867728950e", None),
+ ("/login", None), ("/", None), ("", None),
+ ("/@someowner", None), ("/demo@", None),
+ ("/demo@someowner/%E0%A4%A", None),
+ ("/demo@someowner/music/../../etc", None),
+])
+def test_parse(tmp_path, module_source, route, expected):
+ assert _run(tmp_path, module_source, f"parseGroupLink({json.dumps(route)})") == expected
+
+
+@needs_node
+@pytest.mark.parametrize("group, path", [
+ (GROUPS[0], ""),
+ (GROUPS[0], "backup/city_2015/backup/IMG_0001.JPG"),
+ (GROUPS[2], ""),
+ (GROUPS[3], "music/track 01.flac"),
+ ({"name": "Été à la mer", "owner_username": "someowner"}, "photos/plage #1.jpg"),
+])
+def test_built_routes_parse_back(tmp_path, module_source, group, path):
+ out = _run(tmp_path, module_source,
+ f"(() => {{ const r = groupLinkRoute({json.dumps(group)}, {json.dumps(path)});"
+ f" return [r, parseGroupLink(r)]; }})()")
+ route, parsed = out
+ assert parsed == {"name": group["name"], "owner": group["owner_username"], "path": path}
+ # Nothing that ends or splits a fragment is left bare.
+ assert not re.search(r"[#?\s%](?![0-9A-F]{2})", route)
+
+
+@needs_node
+def test_a_plain_handle_stays_readable(tmp_path, module_source):
+ route = _run(tmp_path, module_source,
+ f"groupLinkRoute({json.dumps(GROUPS[0])}, 'backup/city_2015/IMG_1.JPG')")
+ assert route == "/demo@someowner/backup/city_2015/IMG_1.JPG"
+
+
+@needs_node
+@pytest.mark.parametrize("link, expected", [
+ ({"name": "demo", "owner": "someowner"}, "g1"),
+ ({"name": "demo", "owner": "otherowner"}, "g2"),
+ # The hub keeps names unique on lower(name).
+ ({"name": "DEMO", "owner": "someowner"}, "g1"),
+ ({"name": "demo", "owner": "SomeOwner"}, "g1"),
+ # Not among the account's groups: nothing, and nothing asked of the hub.
+ ({"name": "demo", "owner": "stranger1"}, None),
+ ({"name": "secret", "owner": "someowner"}, None),
+])
+def test_find_among_own_groups(tmp_path, module_source, link, expected):
+ out = _run(tmp_path, module_source,
+ f"(findLinkedGroup({json.dumps(GROUPS)}, {json.dumps(link)}) || {{}}).id || null")
+ assert out == expected
+
+
+@needs_node
+@pytest.mark.parametrize("path, expected", [
+ ("backup/city_2015/backup/IMG_0001.JPG",
+ {"kind": "file", "entry": ENTRIES[0]}),
+ ("backup/city_2015/notes.txt", {"kind": "file", "entry": ENTRIES[1]}),
+ ("backup/city_2015", {"kind": "dir", "dir": "backup/city_2015"}),
+ ("backup", {"kind": "dir", "dir": "backup"}),
+ # An empty folder exists only in the node's own listing.
+ ("backup/empty", {"kind": "dir", "dir": "backup/empty"}),
+ # A prefix of a folder name is not that folder.
+ ("backup/city", None),
+ ("backup/city_2015/backup/img_0001.jpg", None),
+ ("", None),
+])
+def test_resolve_in_index(tmp_path, module_source, path, expected):
+ out = _run(tmp_path, module_source,
+ f"resolveLinkedPath({json.dumps(ENTRIES)}, ['backup/empty'], {json.dumps(path)})")
+ assert out == expected
+
+
+def test_signing_in_keeps_the_page_the_address_names():
+ """A group or file link opened signed out shows the sign-in form in its
+ place; signing in must leave the address alone, not send everyone home."""
+ source = (STATIC / "auth-page.js").read_text(encoding="utf-8")
+ body = source[source.index("export function LoginPage"):]
+ body = body[:body.index("\n}\n")]
+ assert "navigate('/')" not in body and 'navigate("/")' not in body
+ assert "if (loadPending()) navigate('/invite');" in body
+
+
+def test_the_router_resolves_a_handle_and_shows_it():
+ source = (STATIC / "app.js").read_text(encoding="utf-8")
+ body = source[source.index("\nfunction App() {"):]
+ assert "parseGroupLink(route)" in body
+ assert "findLinkedGroup(groups, groupLink)" in body
+ # Rewritten with `replace`: showing the handle is not a history entry.
+ assert "window.location.replace('#' + shownGroupRoute)" in body
+ # The sidebar highlights the group whichever form opened it.
+ assert re.search(r"<\$\{Sidebar\}[\s\S]*?route=\$\{groupRoute\}", body)
diff --git a/packages/meshbay-hub/tests/test_group_link_flow.py b/packages/meshbay-hub/tests/test_group_link_flow.py
new file mode 100644
index 0000000..eb6a353
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_group_link_flow.py
@@ -0,0 +1,67 @@
+"""
+A group link, opened in the real application (harness/group_link_probe.py).
+
+`test_group_link.py` holds the parsing and the lookups; this is where they meet
+the router. A handle opens the group's page, and an id opens it with the handle
+in the address; a path waits in the address for the index to say what it is; a
+handle the account does not know is said to be unknown without the hub ever
+being asked about it; and signed out, the sign-in form stands in for the page
+with the address left alone, which is what lets signing in land there.
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "group_link_probe.py"
+GROUP_HREF = "#/group/0f8fad5b-d9cb-469f-a165-70867728950e"
+FILE_LINK = "#/demo@someowner/backup/city_2015/backup/IMG_0001.JPG"
+
+
+@pytest.fixture(scope="module")
+def cases():
+ if shutil.which("google-chrome") is None:
+ pytest.skip("Chrome is not available")
+ proc = subprocess.run([sys.executable, str(HARNESS)],
+ capture_output=True, text=True, timeout=240)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = {c["case"]: c for c in json.loads(proc.stdout)}
+ for c in out.values():
+ assert "error" not in c, c["error"]
+ return out
+
+
+@pytest.mark.parametrize("case", ["handle", "uuid", "file"])
+def test_a_handle_or_an_id_opens_the_group(cases, case):
+ c = cases[case]
+ assert c["group_page"] and c["group_title"] == "demo@someowner"
+ # Of two groups called "demo", the one whose owner the handle names.
+ assert c["active_sidebar"] == [GROUP_HREF]
+
+
+@pytest.mark.parametrize("case", ["handle", "uuid"])
+def test_the_address_shows_the_handle_without_a_history_entry(cases, case):
+ assert cases[case]["hash"] == "#/demo@someowner"
+ assert cases[case]["history_length"] == 1
+
+
+def test_a_path_stays_in_the_address_until_it_has_been_acted_on(cases):
+ assert cases["file"]["hash"] == FILE_LINK
+
+
+def test_an_unknown_handle_is_said_so_without_asking_the_hub(cases):
+ c = cases["unknown"]
+ assert not c["group_page"] and c["message"] and not c["spinner"]
+ assert c["hash"] == "#/demo@stranger1"
+ for case in cases.values():
+ assert not any("stranger1" in u or "@" in u for u in case["hub_calls"])
+
+
+def test_signed_out_the_form_stands_in_for_the_page(cases):
+ c = cases["signed_out"]
+ assert c["login_form"] and not c["group_page"]
+ assert c["hash"] == FILE_LINK