summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/pyproject.toml4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/__init__.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py42
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py38
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/invite_links.py49
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/middleware.py10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py43
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py101
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py43
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py65
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apple-touch-icon.pngbin0 -> 34590 bytes
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js123
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/favicon.icobin0 -> 7291 bytes
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/invite-page.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/lazy.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/og-image.jpgbin0 -> 33661 bytes
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/robots.txt9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css227
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js60
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js2
-rw-r--r--packages/meshbay-hub/tests/harness/layout_probe.py2
-rw-r--r--packages/meshbay-hub/tests/harness/lazy_failure_probe.py151
-rw-r--r--packages/meshbay-hub/tests/harness/scroll_probe.py2
-rw-r--r--packages/meshbay-hub/tests/harness/video_series_probe.py266
-rw-r--r--packages/meshbay-hub/tests/test_account_pinning.py4
-rw-r--r--packages/meshbay-hub/tests/test_argon2_off_loop.py2
-rw-r--r--packages/meshbay-hub/tests/test_asset_versioning.py2
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py7
-rw-r--r--packages/meshbay-hub/tests/test_browser_idle_signout.py2
-rw-r--r--packages/meshbay-hub/tests/test_bundle_kdf_parity.py8
-rw-r--r--packages/meshbay-hub/tests/test_captcha_host_check.py9
-rw-r--r--packages/meshbay-hub/tests/test_cast_subtitles.py6
-rw-r--r--packages/meshbay-hub/tests/test_challenge_signature_client.py6
-rw-r--r--packages/meshbay-hub/tests/test_chat_scroll_bottom.py2
-rw-r--r--packages/meshbay-hub/tests/test_client_version_gate.py8
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py54
-rw-r--r--packages/meshbay-hub/tests/test_email_change_requires_passphrase.py55
-rw-r--r--packages/meshbay-hub/tests/test_files_drop_upload.py2
-rw-r--r--packages/meshbay-hub/tests/test_files_sorting.py2
-rw-r--r--packages/meshbay-hub/tests/test_group_purge.py2
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py6
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py9
-rw-r--r--packages/meshbay-hub/tests/test_incoming_membership.py76
-rw-r--r--packages/meshbay-hub/tests/test_index_seal_client.py4
-rw-r--r--packages/meshbay-hub/tests/test_indexing_dock.py8
-rw-r--r--packages/meshbay-hub/tests/test_invite_email_choice.py5
-rw-r--r--packages/meshbay-hub/tests/test_invite_link_client.py8
-rw-r--r--packages/meshbay-hub/tests/test_invite_links.py45
-rw-r--r--packages/meshbay-hub/tests/test_layout_measured.py4
-rw-r--r--packages/meshbay-hub/tests/test_layout_responsive.py4
-rw-r--r--packages/meshbay-hub/tests/test_lazy_load_failure.py60
-rw-r--r--packages/meshbay-hub/tests/test_locales.py11
-rw-r--r--packages/meshbay-hub/tests/test_mail_is_not_a_relay.py15
-rw-r--r--packages/meshbay-hub/tests/test_media_pager.py4
-rw-r--r--packages/meshbay-hub/tests/test_member_removal.py2
-rw-r--r--packages/meshbay-hub/tests/test_memory_ceiling.py8
-rw-r--r--packages/meshbay-hub/tests/test_mnp_token.py92
-rw-r--r--packages/meshbay-hub/tests/test_music_queue.py2
-rw-r--r--packages/meshbay-hub/tests/test_node_page_pairing.py48
-rw-r--r--packages/meshbay-hub/tests/test_node_page_width_measured.py2
-rw-r--r--packages/meshbay-hub/tests/test_node_scope_not_admin.py159
-rw-r--r--packages/meshbay-hub/tests/test_notification_dismissal.py4
-rw-r--r--packages/meshbay-hub/tests/test_offer_retry.py2
-rw-r--r--packages/meshbay-hub/tests/test_playlist_crypto.py6
-rw-r--r--packages/meshbay-hub/tests/test_playlist_key.py6
-rw-r--r--packages/meshbay-hub/tests/test_playlist_merge.py6
-rw-r--r--packages/meshbay-hub/tests/test_queue_ops.py6
-rw-r--r--packages/meshbay-hub/tests/test_rate_limit_key.py48
-rw-r--r--packages/meshbay-hub/tests/test_reconnect_refresh.py12
-rw-r--r--packages/meshbay-hub/tests/test_recovery_key.py4
-rw-r--r--packages/meshbay-hub/tests/test_resume_position.py2
-rw-r--r--packages/meshbay-hub/tests/test_revoke_is_one_path.py164
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py4
-rw-r--r--packages/meshbay-hub/tests/test_search_connect_deadline.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_fanout.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_files_unmerged.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_media_merge.py10
-rw-r--r--packages/meshbay-hub/tests/test_search_pool.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_source_merge.py6
-rw-r--r--packages/meshbay-hub/tests/test_season_panel_placement.py10
-rw-r--r--packages/meshbay-hub/tests/test_session_renewal.py14
-rw-r--r--packages/meshbay-hub/tests/test_sidebar_node_section.py55
-rw-r--r--packages/meshbay-hub/tests/test_site_basics.py81
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py12
-rw-r--r--packages/meshbay-hub/tests/test_spa_syntax.py6
-rw-r--r--packages/meshbay-hub/tests/test_streamed_download_reliability.py14
-rw-r--r--packages/meshbay-hub/tests/test_table_rows_measured.py2
-rw-r--r--packages/meshbay-hub/tests/test_token_hardening.py128
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py34
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py12
-rw-r--r--packages/meshbay-hub/tests/test_upload_seal_client.py4
-rw-r--r--packages/meshbay-hub/tests/test_versions_agree.py6
-rw-r--r--packages/meshbay-hub/tests/test_video_audio_track.py6
-rw-r--r--packages/meshbay-hub/tests/test_video_buffer_ceiling.py6
-rw-r--r--packages/meshbay-hub/tests/test_video_default_season.py6
-rw-r--r--packages/meshbay-hub/tests/test_video_detail_measured.py4
-rw-r--r--packages/meshbay-hub/tests/test_video_reconnect.py4
-rw-r--r--packages/meshbay-hub/tests/test_video_seek.py12
-rw-r--r--packages/meshbay-hub/tests/test_video_series_stays_open.py74
-rw-r--r--packages/meshbay-hub/tests/test_video_stream_switch.py6
-rw-r--r--packages/meshbay-hub/tests/test_video_subtitles.py6
-rw-r--r--packages/meshbay-hub/tests/test_welcome_layout_measured.py48
-rw-r--r--packages/meshbay-hub/tests/test_zip_size_limit.py6
-rw-r--r--packages/meshbay-hub/tests/test_zipstream.py16
130 files changed, 2971 insertions, 670 deletions
diff --git a/packages/meshbay-hub/pyproject.toml b/packages/meshbay-hub/pyproject.toml
index 9ead36e..fb64cf6 100644
--- a/packages/meshbay-hub/pyproject.toml
+++ b/packages/meshbay-hub/pyproject.toml
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
[project]
name = "meshbay-hub"
-version = "0.15.0"
+version = "0.16.0"
description = "MeshBay Hub — identity authority and group registry server"
requires-python = ">=3.12"
dependencies = [
"meshbay-common>=0.10.0",
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
- "sqlalchemy>=2.0",
+ "sqlalchemy[asyncio]>=2.0",
"alembic>=1.13",
"asyncpg>=0.30", # PostgreSQL async driver
"aiosqlite>=0.20", # SQLite async (tests + dev without PostgreSQL)
diff --git a/packages/meshbay-hub/src/meshbay_hub/__init__.py b/packages/meshbay-hub/src/meshbay_hub/__init__.py
index 8762e35..1ea6708 100644
--- a/packages/meshbay-hub/src/meshbay_hub/__init__.py
+++ b/packages/meshbay-hub/src/meshbay_hub/__init__.py
@@ -1,3 +1,3 @@
"""MeshBay Hub — identity authority and group registry."""
-__version__ = "0.15.0"
+__version__ = "0.16.0"
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index b4b2f4f..381378c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -329,6 +329,25 @@ async def admin_patch_user(
raise HTTPException(
status_code=422,
detail="status must be active, suspended, or revoked")
+ # Revoking is not a status write. It is signed and broadcast to every
+ # node so the account is refused there at once; PATCH would change only
+ # the hub row and leave the nodes unaware — a "revoked" that is not the
+ # revoked the design promises (§7.5). One door, and it broadcasts.
+ # (A moderator was already refused above by `privileged`; this is the
+ # admin, who is told the right door rather than given a broken one.)
+ if body.status == "revoked":
+ raise HTTPException(
+ status_code=400,
+ detail="Revoke through POST /v1/admin/revoke — it signs the "
+ "revocation and broadcasts it to every node.")
+ # Leaving `revoked` undoes a signed, node-enforced action, so it is an
+ # admin's call, never a moderator's: the nodes still deny this account
+ # from the broadcast, and a moderator flipping the hub row back to
+ # active would only disagree with them.
+ if user.status == "revoked" and not user_is_admin(current_user):
+ raise HTTPException(
+ status_code=403,
+ detail="Only an admin can change a revoked account.")
user.status = body.status
log.info("User %s status changed to %s by %s",
user.username, body.status, current_user.username)
@@ -489,6 +508,29 @@ async def admin_patch_group(
raise HTTPException(
status_code=422,
detail="status must be active, suspended, or revoked")
+ # Suspend/unsuspend is a moderator's reversible, hub-only lever (§7.5).
+ # Revoke is neither: it is signed and broadcast to every node, and it is
+ # an administrative act — the same line `admin_patch_user` draws. Two
+ # gaps used to sit here: a moderator could set `revoked`, and a
+ # `revoked` set through this PATCH was never broadcast, so it behaved
+ # like `suspended` on nodes while claiming to be the signed, enforced
+ # state. Revoke has one door, `POST /v1/admin/revoke`, and it broadcasts.
+ if body.status == "revoked":
+ if not user_is_admin(current_user):
+ raise HTTPException(
+ status_code=403,
+ detail="Revoking a group requires admin rights.")
+ raise HTTPException(
+ status_code=400,
+ detail="Revoke a group through POST /v1/admin/revoke — it signs "
+ "the revocation and broadcasts it to every node.")
+ # Leaving `revoked` undoes that broadcast and is an admin's call: the
+ # nodes still enforce the revocation, and a moderator flipping the hub
+ # row back would only disagree with them.
+ if group.status == "revoked" and not user_is_admin(current_user):
+ raise HTTPException(
+ status_code=403,
+ detail="Only an admin can change a revoked group.")
group.status = body.status
log.info("Group %s status changed to %s by %s",
group.name, body.status, current_user.username)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
index 501be9d..42f4101 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
@@ -59,16 +59,34 @@ async def get_current_user(
return user
-async def require_user_scope(
- payload: dict = Depends(_decode_token),
- current_user: User = Depends(get_current_user),
-) -> User:
- """Reject node-scoped tokens — only browser (user-scope) can mutate groups."""
+def _reject_node_scope(payload: dict) -> None:
+ """Refuse a node-scoped daemon token on a route meant for a person.
+
+ A node's authority and a hub role are **different notions**. What a node may
+ do is decided by its operator's roster pin on the node itself (NS4) and by
+ the node scope's deliberately narrow reach; being an admin or a moderator is
+ a hub role attached to a person's account. A node daemon authenticates with
+ the node key and receives a `scope:"node"` token so that the machine can
+ register, signal and host — never so that it can act as its operator on the
+ hub. When the operator's account happens to also hold a hub role, that role
+ is the *person's*, exercised from a browser with a user-scoped token, and
+ must not be reachable by a token the daemon holds in memory. So the scope
+ gate lives in one place and fronts every privileged dependency, not only
+ group mutation.
+ """
if payload.get("scope") == "node":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Node-scoped token cannot perform this operation — use browser",
)
+
+
+async def require_user_scope(
+ payload: dict = Depends(_decode_token),
+ current_user: User = Depends(get_current_user),
+) -> User:
+ """Reject node-scoped tokens — only browser (user-scope) can mutate groups."""
+ _reject_node_scope(payload)
return current_user
@@ -84,8 +102,13 @@ def user_is_moderator(user: User) -> bool:
async def require_moderator(
+ payload: dict = Depends(_decode_token),
current_user: User = Depends(get_current_user),
) -> User:
+ # A node-scoped daemon token is refused here even for a moderator's own
+ # account: the hub moderation surface (suspending accounts, reading the IP
+ # audit log, listing nodes) is the person's, not the machine's.
+ _reject_node_scope(payload)
if not user_is_moderator(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Moderator access required")
@@ -93,8 +116,13 @@ async def require_moderator(
async def require_admin(
+ payload: dict = Depends(_decode_token),
current_user: User = Depends(get_current_user),
) -> User:
+ # Likewise: revoking accounts and groups (signed, broadcast to every node)
+ # and changing instance policy are administrative acts a person performs
+ # from a browser, never something a node token may reach.
+ _reject_node_scope(payload)
if not user_is_admin(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index cab60c8..efebc4d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -78,8 +78,13 @@ async def hub_pubkey():
# handshake refusal anyway. The operator is updating every client, node and hub
# by hand for this flag day, which is what makes that acceptable exactly once.
# The gate is in place for the next one, where it will work as intended.
-MIN_CLIENT_VERSION = "0.13.0"
-RECOMMENDED_CLIENT_VERSION = "0.13.0"
+#
+# 0.16.0 is the MNP 4.0 flag day: a client older than this presents its hub
+# session token to a node, which a 4.0 node refuses. A desktop client below
+# 0.16.0 is told to update *before* it connects rather than meeting a handshake
+# refusal it cannot read; the browser reloads this build from the hub.
+MIN_CLIENT_VERSION = "0.16.0"
+RECOMMENDED_CLIENT_VERSION = "0.16.0"
@router.get("/version")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
index f33dc6e..3c50e19 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
@@ -4,14 +4,13 @@ Invitation links — the hub's half (docs/MESHBAY_DESIGN.md §3.4, §7.3).
A link carries two secrets with two jobs. The node's code decides who gets the
group key, and it is shown to the hub only when the inviter asks the hub to mail
the link. The ticket here decides who may *reach* the node — membership,
-which is all the hub has to give (§7.1) — and it gives it to one account only:
-the one whose verified address the inviter named. A ticket that leaks, through a
-messaging service that previews links or a forwarded mail, is therefore useless
-without that mailbox.
+which is all the hub has to give (§7.1) — and it gives it to one account: the
+first to redeem it. Both halves are therefore bearer secrets, so the link can
+travel through any messaging service; what bounds a leaked one is that it works
+once, for seven days, and that the owner can cancel it.
-Stated per the design's convention: that binding holds against third parties and
-not against this hub, which verifies the addresses it compares. An active hub
-could already be anybody.
+The address is optional and binds nothing. When given, it is where the hub
+mails the link and a masked label in the owner's list.
No route here answers without an account, and nothing tells the inviter whether
an address has an account (M1): creating a link looks the same either way.
@@ -30,7 +29,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub import mail
from meshbay_hub.api.deps import _decode_token, get_current_user, require_user_scope
from meshbay_hub.api.middleware import limiter
-from meshbay_hub.auth import hash_email_blind
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User
@@ -40,9 +38,9 @@ redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"])
# The node holds at most as many unredeemed link codes per group; one more
# ticket here than codes there would be a link that cannot work.
MAX_OUTSTANDING_PER_GROUP = 20
-# A node's invitation lifetime is the operator's setting (7 days by default);
-# the ticket follows it, up to this.
-MAX_LIFETIME = timedelta(days=30)
+# The node issues a link's code for exactly this long; the ticket follows it,
+# and never outlives it.
+MAX_LIFETIME = timedelta(days=7)
# How long a spent link is kept before it is forgotten. The owner is not shown
# it — the person is in the group — but while the row is here, a reload or a
# second tab of the invitation page still answers the account that used it.
@@ -83,6 +81,8 @@ def _aware(when: datetime) -> datetime:
def _valid_email(v: str) -> str:
v = v.strip()
+ if not v:
+ return v
local, sep, domain = v.partition("@")
if (not sep or not local or not domain or "." not in domain.strip(".")
or len(v) > 254 or any(c.isspace() or ord(c) < 32 for c in v)):
@@ -91,7 +91,8 @@ def _valid_email(v: str) -> str:
class CreateLinkRequest(BaseModel):
- email: str
+ # Optional: only where the hub mails the link, and a label for the owner.
+ email: str = ""
expires_at: str
node_invite_id: str
# Only when the hub is to mail the link, since only then must it write it:
@@ -149,6 +150,8 @@ async def create_invite_link(
if not _NODE_INVITE_ID.match(body.node_invite_id):
raise HTTPException(status_code=422, detail="Not a node invitation id")
if body.send_email:
+ if not body.email:
+ raise HTTPException(status_code=422, detail="Mailing a link needs an address")
if payload.get("scope") == "node":
raise HTTPException(status_code=403,
detail="Invitation mail is sent from the interface only")
@@ -179,7 +182,7 @@ async def create_invite_link(
ticket = secrets.token_urlsafe(16)
row = GroupInviteLink(
group_id=group_id, created_by=current_user.id, ticket_hash=ticket_hash(ticket),
- email_hash=hash_email_blind(body.email), email_masked=_mask(body.email),
+ email_masked=_mask(body.email) if body.email else None,
node_invite_id=body.node_invite_id, expires_at=expires)
db.add(row)
await db.flush()
@@ -225,7 +228,7 @@ async def list_invite_links(
now = datetime.now(UTC)
return {"links": [{
"link_id": r.id,
- "email": r.email_masked,
+ "email": r.email_masked or "",
"node_invite_id": r.node_invite_id,
"created_at": _aware(r.created_at).isoformat(),
"expires_at": _aware(r.expires_at).isoformat(),
@@ -257,14 +260,10 @@ async def delete_invite_link(
async def _resolve(db: AsyncSession, ticket: str, user: User
) -> tuple[GroupInviteLink, Group]:
"""
- The link this ticket names, if this account may use it.
-
- One uniform refusal for everything that says nothing about the account —
- unknown, spent by someone else, expired, a group no longer active — and one
- distinct answer, `invite_other_account`, for the case the person can act
- on: signed in as somebody other than the address it was sent to. That
- answer names no address, and it is given only to someone holding the
- ticket, who already knows a link exists.
+ The link this ticket names, if this account may use it: any account while
+ nobody has, and afterwards only the one that did. One uniform refusal for
+ everything else — unknown, spent by someone else, expired, a group no
+ longer active.
"""
invalid = HTTPException(status_code=404, detail="invite_not_valid")
if not _TICKET.match(ticket or ""):
@@ -280,8 +279,6 @@ async def _resolve(db: AsyncSession, ticket: str, user: User
raise invalid
if not row.redeemed_by and _aware(row.expires_at) <= datetime.now(UTC):
raise invalid
- if not user.email_hash or user.email_hash != row.email_hash:
- raise HTTPException(status_code=403, detail="invite_other_account")
return row, group
@@ -312,8 +309,8 @@ async def redeem_invite_link(
db: AsyncSession = Depends(get_db),
):
"""
- Membership for the addressed account, once — and the same answer again for
- that account, because a second tab or a reload is the same person.
+ Membership for the first account that asks, once — and the same answer
+ again for that account, because a second tab or a reload is the same person.
"""
row, group = await _resolve(db, body.ticket, current_user)
if not row.redeemed_by:
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/middleware.py b/packages/meshbay-hub/src/meshbay_hub/api/middleware.py
index bed7b54..3a2a5c3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/middleware.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/middleware.py
@@ -7,7 +7,11 @@ to mitigate credential stuffing and registration floods.
"""
from slowapi import Limiter
-from slowapi.util import get_remote_address
-# Rate limiter instance — mounted on the FastAPI app in app.py
-limiter = Limiter(key_func=get_remote_address)
+from meshbay_hub.api.netutil import client_ip
+
+# Rate limiter instance — mounted on the FastAPI app in app.py.
+# Keyed on client_ip, not slowapi's get_remote_address: behind Caddy every
+# request's peer is loopback, so the peer address put the whole internet in one
+# bucket — ten node sign-ins a minute, shared by every node there is.
+limiter = Limiter(key_func=client_ip)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index 7478173..403f450 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -11,15 +11,54 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.api.deps import get_current_user, require_user_scope
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
-from meshbay_hub.auth import issue_access_token
+from meshbay_hub.auth import issue_access_token, issue_mnp_token
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import GroupMember, IPLog, Node, User
router = APIRouter(prefix="/v1/nodes", tags=["nodes"])
+
+class MnpTokenRequest(BaseModel):
+ # The base64 Ed25519 key of the node this token is for. The token is bound
+ # to it (E10), so it cannot be replayed to another node. The client knows it
+ # from `/v1/groups/{id}/nodes` before it connects.
+ node_pk: str = ""
+
+
+@router.post("/mnp-token")
+@limiter.limit("60/minute")
+async def mnp_token(
+ request: Request,
+ body: MnpTokenRequest | None = None,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """Mint the short-lived token a member presents to a node in the MNP handshake.
+
+ Asked for with the member's own session token (require_user_scope, so a node
+ daemon token cannot mint one). The result carries the member's current group
+ membership, `aud=MNP_AUD` and the target node's key, so it authorises the
+ member to **that** node only and is refused by the hub API and by any other
+ node. Short-lived on purpose; the client refetches it for a new connection or
+ a reconnect, and it is checked only at the handshake, so a film already
+ playing is never interrupted by its expiry.
+
+ The hub does not verify the node key it is handed — binding the token to it
+ only *restricts* the token to whatever node holds that key, which is the one
+ the client is connecting to; a wrong key yields a token no node will accept.
+ """
+ rows = await db.execute(
+ select(GroupMember.group_id).where(GroupMember.user_id == current_user.id))
+ group_ids = [gid for (gid,) in rows.all()]
+ return {
+ "mnp_token": issue_mnp_token(current_user.id, groups=group_ids,
+ node_pk=(body.node_pk if body else "")),
+ "expires_in": 900,
+ }
+
NODE_AUTH_TIMESTAMP_WINDOW = 60 # seconds
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index f8cae8a..2c0b8db 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -441,6 +441,7 @@ async def notify_incoming(
body: IncomingRequest,
request: Request,
current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
):
"""
Signal a node that a client wants to connect (NAT punch coordination).
@@ -450,8 +451,18 @@ async def notify_incoming(
arbitrary node emit UDP packets to an address of their choosing — a small
reflection primitive using someone else's machine. The probe target must now be
the caller's own source address.
+
+ Like the offer relay, the caller must share an active group with the node —
+ checked **before** anything reveals whether the node is connected, so this is
+ not a liveness oracle a stranger can poll, and a stranger cannot make a node
+ punch on their behalf.
"""
from meshbay_hub.api.netutil import client_ip
+ from meshbay_hub.api.signaling import require_shared_active_group
+
+ # First, and before anything reveals whether the node is connected: a
+ # stranger cannot poll this for a node's liveness, nor make it punch.
+ await require_shared_active_group(db, node_id, current_user.id)
caller_ip = client_ip(request)
if body.peer_ip != caller_ip:
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
index 60d5e20..fc40204 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
@@ -102,6 +102,51 @@ def _take_offer(user_id: str, node_id: str, now: float) -> float | None:
return None
+async def require_shared_active_group(db: AsyncSession, node_id: str, user_id: str) -> None:
+ """The caller must share an **active** group with this node, or the node must
+ host an open group while public groups are on (that path *is* what a public
+ group means, so it follows the instance switch). Raises 403 with a uniform
+ message otherwise — the same answer whether the node is a member's or a
+ stranger's, and whether it is connected or not, so it is not a liveness
+ oracle for a non-member.
+
+ Membership is read from the connected-node registry, so a node hosting no
+ group shares one with nobody (AV24, AV1): the empty claim is "no groups", not
+ "all of its owner's". Both the WebRTC offer relay and the NAT-punch signal
+ call this, so they gate the same way (H6).
+ """
+ from meshbay_hub.api.revocation import _node_groups
+ node_group_ids = set(_node_groups.get(node_id, []))
+ if not node_group_ids:
+ raise HTTPException(status_code=403,
+ detail="Not a member of any group on this node")
+ shared = [gid for (gid,) in (await db.execute(
+ select(GroupMember.group_id).where(
+ GroupMember.user_id == user_id,
+ GroupMember.group_id.in_(node_group_ids),
+ ))).all()]
+ if not shared:
+ has_open = None
+ if await hub_settings.public_groups_allowed(db):
+ has_open = (await db.execute(
+ select(Group.id).where(
+ Group.id.in_(node_group_ids),
+ Group.join_policy == "open",
+ Group.status == "active",
+ ))).first()
+ if not has_open:
+ raise HTTPException(status_code=403,
+ detail="Not a member of any group on this node")
+ return
+ statuses = set((await db.execute(
+ select(Group.status).where(Group.id.in_(shared)))).scalars().all())
+ if "active" not in statuses:
+ # Report the strongest state present — "revoked" is the signed,
+ # node-enforced one; "suspended" is the reversible hub flag.
+ state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended")
+ raise HTTPException(status_code=403, detail=f"Group is {state}")
+
+
@router.post("/{node_id}/webrtc/offer", response_model=WebRTCOfferResponse)
# Per address and per node, and only a coarse guard in front of authentication:
# the account's budget above is the limit that means something. 600 because an
@@ -143,58 +188,10 @@ async def webrtc_offer(
if not ws:
raise HTTPException(status_code=404, detail="Node not connected")
- # The caller must share at least one active group with the target node,
- # OR the node must host at least one open-join group (public groups admit
- # anyone — the node's MNP handshake handles authorization).
- #
- # That second path is exactly what "public groups" means, so it is gated by
- # the instance switch: with public groups off, a non-member is not brokered a
- # connection to a node just because it happens to host an open group. Members
- # of that group are unaffected — they match `shared` below.
- node_group_ids = set(_node_groups.get(node_id, []))
- # A node registered for no group shares no group with anybody, which is this
- # check's own answer — and `if node_group_ids:` used to skip the whole thing,
- # membership, group status and the public-group gate together. Since AV1 made
- # an empty claim mean "no groups" rather than "all of my owner's", that is
- # the *normal* registration of a node hosting nothing: exactly the
- # unconfigured node left running that took a group down on 2026-09-11. So the
- # machine least able to defend itself was the one any authenticated account
- # could make allocate a peer connection and gather ICE, which is H6 restored
- # in the one case AV1 made common.
- #
- # Nothing legitimate is lost by refusing here: a browser cannot complete a
- # handshake with such a node anyway — `group_id` is mandatory (M1) and a node
- # holding no group key refuses outright (NS8) — so this only declines work
- # the node would decline one step later, at its own expense.
- if not node_group_ids:
- raise HTTPException(status_code=403,
- detail="Not a member of any group on this node")
-
- result = await db.execute(
- select(GroupMember.group_id).where(
- GroupMember.user_id == current_user.id,
- GroupMember.group_id.in_(node_group_ids),
- ))
- shared = [gid for (gid,) in result.all()]
- if not shared:
- has_open = None
- if await hub_settings.public_groups_allowed(db):
- has_open = (await db.execute(
- select(Group.id).where(
- Group.id.in_(node_group_ids),
- Group.join_policy == "open",
- Group.status == "active",
- ))).first()
- if not has_open:
- raise HTTPException(status_code=403, detail="Not a member of any group on this node")
- else:
- statuses = set((await db.execute(
- select(Group.status).where(Group.id.in_(shared)))).scalars().all())
- if "active" not in statuses:
- # Report the strongest state present — "revoked" is the signed,
- # node-enforced one; "suspended" is the reversible hub flag.
- state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended")
- raise HTTPException(status_code=403, detail=f"Group is {state}")
+ # The caller must share an active group with the node (or the node must host
+ # an open group when public groups are on). One implementation, shared with
+ # the NAT-punch signal (`notify_incoming`), so both gate the same way.
+ await require_shared_active_group(db, node_id, current_user.id)
# Both refusals say when to come back, and transport.js does: a 429 here is
# the hub being busy, never the node being down.
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index a994acb..7046c2f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -728,6 +728,14 @@ async def get_current_user_info(
class UpdateProfileRequest(BaseModel):
email: str | None = None
+ # Required to change the address on file. Changing it is the first step of
+ # an account takeover from a bare access token: the confirmation code goes
+ # to the new (attacker) address, and a verified address then unlocks the
+ # passphrase-reset path. A live access token is not enough for that — the
+ # passphrase is, exactly as for `change_password` and `delete_own_account`.
+ # This matters because a member hands its access token to every node it
+ # connects to (the MNP handshake), so a node operator holds one.
+ auth_key: str | None = None
@field_validator("email")
@classmethod
@@ -768,6 +776,22 @@ async def update_profile(
new_email = body.email.strip()
eh = hash_email_blind(new_email)
+ # Changing the address on file requires the passphrase, not merely a
+ # live token. Same second factor, and the same throttle, as a passphrase
+ # change or an account deletion — the hub still never sees the
+ # passphrase, only the derived auth_key.
+ if not body.auth_key:
+ raise HTTPException(
+ status_code=403,
+ detail="Changing your e-mail requires your passphrase.")
+ await _take_login_attempt(db, current_user.username)
+ if not await verify_password_off_loop(
+ body.auth_key, current_user.pw_hash, current_user.pw_salt,
+ current_user.pw_version):
+ raise HTTPException(status_code=403,
+ detail="Passphrase does not match")
+ await login_throttle.clear(db, current_user.username)
+
# How often one account may point the hub at a *different* address.
# Long, because this is the only path where a signed-in account chooses
# who receives a message, and a short delay alone still allows one
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 626c639..c9cb8d5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -12,6 +12,7 @@ The root route (/) returns the SPA HTML shell.
"""
import hashlib
+import html
from pathlib import Path
from fastapi import APIRouter
@@ -142,17 +143,49 @@ CSP = "; ".join([
@router.get("/app", response_class=HTMLResponse)
async def app_root():
- return HTMLResponse(_HTML, headers=_NO_STORE)
+ return HTMLResponse(_shell, headers=_NO_STORE)
@router.get("/app/{path:path}", response_class=HTMLResponse)
async def app_catchall(path: str):
- return HTMLResponse(_HTML, headers=_NO_STORE)
+ return HTMLResponse(_shell, headers=_NO_STORE)
@router.get("/", response_class=HTMLResponse)
async def index():
- return HTMLResponse(_HTML, headers=_NO_STORE)
+ return HTMLResponse(_shell, headers=_NO_STORE)
+
+
+# What a messenger draws for a link to this hub: an invitation, or the address
+# itself. Signal and WhatsApp read these tags and nothing else, and resolve only
+# an absolute og:image, so the shell is rendered for the hub's public name —
+# `identity.id`, the name mail links already use. Short on purpose: a preview
+# shows a line or two of the description and cuts the rest. The image is the
+# square icon, which is the shape a thumbnail is cut to anyway.
+PREVIEW_DESCRIPTION = "Your files stay at home. Reach them from anywhere."
+
+
+def _preview_tags(hub_id: str) -> str:
+ origin = html.escape(f"https://{hub_id}", quote=True)
+ return f"""\
+ <meta name="description" content="{PREVIEW_DESCRIPTION}">
+ <meta property="og:type" content="website">
+ <meta property="og:site_name" content="MeshBay">
+ <meta property="og:title" content="MeshBay">
+ <meta property="og:description" content="{PREVIEW_DESCRIPTION}">
+ <meta property="og:url" content="{origin}/">
+ <meta property="og:image" content="{origin}/og-image.jpg">
+ <meta property="og:image:type" content="image/jpeg">
+ <meta property="og:image:width" content="600">
+ <meta property="og:image:height" content="600">
+ <meta property="og:image:alt" content="The MeshBay logo">
+"""
+
+
+def configure(hub_id: str) -> None:
+ """Render the shell for this hub's public name (`identity.id`)."""
+ global _shell
+ _shell = _HTML.replace("{preview}", _preview_tags(hub_id))
_HTML = """\
@@ -171,6 +204,8 @@ _HTML = """\
<meta name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<title>MeshBay</title>
+{preview} <link rel="icon" href="/a/{v}/favicon.ico" sizes="16x16 32x32 48x48">
+ <link rel="apple-touch-icon" href="/a/{v}/apple-touch-icon.png">
<link rel="stylesheet" href="/a/{v}/style.css">
</head>
<body>
@@ -205,3 +240,5 @@ _HTML = """\
</body>
</html>
""".replace("{v}", ASSET_V)
+
+_shell = _HTML.replace("{preview}", _preview_tags("meshbay.org"))
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index b1d9626..7ccf598 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -37,6 +37,7 @@ from meshbay_hub.api.signaling import router as signaling_router
from meshbay_hub.api.users import router as users_router
from meshbay_hub.api.users import set_config as users_set_config
from meshbay_hub.api.webapp import ASSET_V, CSP, STATIC_DIR
+from meshbay_hub.api.webapp import configure as webapp_configure
from meshbay_hub.api.webapp import router as webapp_router
from meshbay_hub.auth import generate_hub_keypair, load_hub_keypair
from meshbay_hub.config import HubConfig
@@ -97,6 +98,9 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
from meshbay_hub.config import load_config
if cfg is None:
cfg = load_config()
+ # Here rather than in the lifespan: the shell is served by routes that exist
+ # as soon as the app does, and a test client need not start it to read them.
+ webapp_configure(cfg.identity.id)
@asynccontextmanager
async def lifespan(app: FastAPI):
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index d038027..0d0fd95 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -25,6 +25,8 @@ from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from meshbay_common.tokens import HUB_API_AUD, MNP_AUD
+
# Argon2id parameters — versioned for gradual migration
_ARGON2_LANES = 4
_ARGON2_KEY_LEN = 32
@@ -230,17 +232,76 @@ def issue_access_token(
"exp": now + ttl,
"groups": groups or [],
"scope": scope,
+ # This is a hub-API credential. The node handshake binds MNP_AUD and
+ # refuses it, so a session token disclosed to a node opens nothing at
+ # the hub (see meshbay_common.tokens).
+ "aud": HUB_API_AUD,
+ }
+ return jwt.encode(payload, _hub_sk_pem, algorithm="EdDSA")
+
+
+def issue_mnp_token(user_id: str, groups: list[str] | None = None,
+ node_pk: str | None = None, ttl: int = 900) -> str:
+ """Issue the short-lived token a member presents to a node in the handshake.
+
+ `aud=MNP_AUD`, so it is accepted by `authorize_token` and refused by the hub
+ API. It is checked once, at the handshake, before any proof — so a short
+ lifetime does not interrupt a long transfer or a film already playing; only a
+ fresh connection or a reconnect needs a fresh one. It carries the same
+ `sub`/`groups`/`jti` the node authorises and denylists on.
+
+ `node` names the node this token is for (its base64 Ed25519 key), so it
+ cannot be replayed to another node the member also belongs to — the node
+ checks it in `authorize_token` (E10). The client knows the target node's key
+ before it connects and asks for a token bound to it.
+ """
+ if _hub_sk_pem is None:
+ raise RuntimeError("Hub keypair not loaded")
+ now = int(time.time())
+ payload = {
+ "iss": _hub_id,
+ "sub": user_id,
+ "jti": str(uuid.uuid4()),
+ "iat": now,
+ "exp": now + ttl,
+ "groups": groups or [],
+ "node": node_pk or "",
+ "scope": "user",
+ "aud": MNP_AUD,
}
return jwt.encode(payload, _hub_sk_pem, algorithm="EdDSA")
def decode_access_token(token: str) -> dict:
- """Verify and decode an access token. Raises on failure."""
+ """Verify and decode an access token. Raises on failure.
+
+ This is the **hub-API** decode. It binds `audience=HUB_API_AUD` and requires
+ `exp`, `sub` and `scope`. One Ed25519 key signs several kinds of token —
+ session tokens (aud=HUB_API_AUD), the MNP token a member presents to a node
+ (aud=MNP_AUD), revocation broadcasts (no `exp`/`sub`, handed to admins and
+ pushed to every node), and MHP federation tokens (aud=peer hub). Binding the
+ audience here means only a session token opens the hub API: an **MNP token
+ disclosed to a node cannot be replayed against the hub**, which is the whole
+ point of splitting the two (see meshbay_common.tokens). Requiring `exp`
+ refuses any hub-signed token with no expiry, and `scope` must still name one
+ of the two access scopes.
+
+ The node handshake uses its own decode (`meshbay_common.handshake`), which
+ binds `MNP_AUD` instead; the node's own self-decode of its node token passes
+ `audience=HUB_API_AUD` (hub_client.py), so both sides move together.
+ """
if _hub_pk_pem is None:
raise RuntimeError("Hub keypair not loaded")
# Clock-skew tolerance (meshbay_common.handshake.JWT_LEEWAY_SECONDS): a
# client whose clock is a little fast must still be able to call the API.
- return jwt.decode(token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60)
+ payload = jwt.decode(
+ token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60,
+ audience=HUB_API_AUD,
+ options={"require": ["exp", "sub", "scope", "aud"]},
+ )
+ if payload.get("scope") not in ("user", "node"):
+ raise jwt.InvalidTokenError("unrecognised token scope")
+ return payload
# ── Email encryption at rest ──────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py
new file mode 100644
index 0000000..ae9d2c9
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py
@@ -0,0 +1,34 @@
+"""invitation links bind no address
+
+A link is now redeemable by whichever account opens it first, so it can be sent
+through any messaging service. The address, when the inviter gives one, is only
+where the hub mails the link and a masked label in the owner's list.
+
+Revision ID: c4d5e6f7a8b9
+Revises: b2c3d4e5f6a7
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "c4d5e6f7a8b9"
+down_revision: str | Sequence[str] | None = "b2c3d4e5f6a7"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("group_invite_links") as batch:
+ batch.drop_column("email_hash")
+ batch.alter_column("email_masked", existing_type=sa.String(128), nullable=True)
+
+
+def downgrade() -> None:
+ # The bound address cannot be recovered; outstanding links are dropped.
+ op.execute("DELETE FROM group_invite_links")
+ with op.batch_alter_table("group_invite_links") as batch:
+ batch.alter_column("email_masked", existing_type=sa.String(128), nullable=False)
+ batch.add_column(sa.Column("email_hash", sa.String(64), nullable=False,
+ server_default=""))
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 51a3d47..09eb437 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -151,12 +151,12 @@ class GroupInviteLink(Base):
A link carries two secrets. The node's code decides whether someone gets the
group key, and the hub never sees it. This row decides whether someone may
- *reach* the node at all — membership, which is all the hub has to give — and
- only for the account whose verified address matches `email_hash`. The ticket
- is stored as `sha256(ticket)`, so a copy of this table opens nothing.
+ *reach* the node at all — membership, which is all the hub has to give — for
+ the first account that redeems it. The ticket is stored as `sha256(ticket)`,
+ so a copy of this table opens nothing.
- No address in the clear: `email_hash` is the same blind index `users` has,
- and `email_masked` is what the owner's list shows (`al***@ex***.com`).
+ The address is optional and binds nothing: when the inviter gave one,
+ `email_masked` is what the owner's list shows (`al***@ex***.com`).
"""
__tablename__ = "group_invite_links"
@@ -164,8 +164,7 @@ class GroupInviteLink(Base):
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), nullable=False)
created_by: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
ticket_hash: Mapped[str] = mapped_column(String(64), nullable=False)
- email_hash: Mapped[str] = mapped_column(String(64), nullable=False)
- email_masked: Mapped[str] = mapped_column(String(128), nullable=False)
+ email_masked: Mapped[str | None] = mapped_column(String(128))
# The node's handle for its half, so cancelling can take back both.
node_invite_id: Mapped[str] = mapped_column(String(32), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py
index d36a829..b382849 100644
--- a/packages/meshbay-hub/src/meshbay_hub/mail.py
+++ b/packages/meshbay-hub/src/meshbay_hub/mail.py
@@ -469,7 +469,7 @@ def send_invite_link(to: str, link: str, inviter: str, group_name: str) -> None:
"\n"
f"{link}\n"
"\n"
- "It works once, and only for an account registered with this address.\n"
+ "It works once, and for seven days.\n"
"If you did not expect it, you can ignore this message.\n"
)
_send(msg, purpose="invite_link")
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 6b20a63..3101be7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -924,8 +924,31 @@ function App() {
.catch(() => {});
}, [user, notifDisabled]);
+ // Whether this account has a node linked, which is what shows the sidebar's
+ // Node section. Asked again whenever a node may just have been linked (the
+ // Create Group wizard), not only when the session changes -- the section
+ // stayed hidden until a reload after the first group -- and retried rather
+ // than left false by one failed request after a session blip.
+ const nodeKeyAskedForRef = useRef(null);
+ const refreshNodeKey = useCallback(() => {
+ nodeKeyAskedForRef.current = user;
+ if (!user || !platform.capabilities.nodeAdmin) return;
+ // A late answer, or a retry, must not land on a session that has changed.
+ const current = () => nodeKeyAskedForRef.current === user;
+ const ask = (attempt) => hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
+ .then(data => { if (current()) setHasNodeKey(Boolean(data.pk_node_ed25519)); })
+ .catch(() => {
+ if (attempt < 3 && current()) setTimeout(() => ask(attempt + 1), 2000 * attempt);
+ });
+ ask(1);
+ }, [user]);
+
useEffect(() => {
- if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false); return; }
+ if (!user) {
+ nodeKeyAskedForRef.current = null;
+ setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false);
+ return;
+ }
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => setGroups([]));
@@ -935,11 +958,7 @@ function App() {
if (prefs.notifications_disabled === 'true') setNotifDisabled(true);
})
.catch(() => {});
- if (platform.capabilities.nodeAdmin) {
- hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
- .then(data => setHasNodeKey(Boolean(data.pk_node_ed25519)))
- .catch(() => {});
- }
+ refreshNodeKey();
fetchNotifications();
}, [user]);
@@ -1176,10 +1195,12 @@ function App() {
} else if (route === '/create-group') {
page = html`<${LazyCreateGroupPage} token=${user.token} username=${user.username}
allowPublicGroups=${allowPublicGroups}
+ onNodeLinked=${refreshNodeKey}
onCreated=${() => {
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => {});
+ refreshNodeKey();
}} />`;
} else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) {
page = html`<${LazyNodePage} groups=${groups} token=${user.token} username=${user.username} />`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apple-touch-icon.png b/packages/meshbay-hub/src/meshbay_hub/static/apple-touch-icon.png
new file mode 100644
index 0000000..aacd0fa
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apple-touch-icon.png
Binary files differ
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 70c48f2..09c4acf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -207,7 +207,9 @@ export function LoginPage({ onLogin }) {
return html`
<div class="page-center">
+ <div class="welcome-backdrop" aria-hidden="true"></div>
<div class="welcome">
+ <div class="welcome-side">
<div class="card login-card">
<h2>${t('login.title')}</h2>
<form onSubmit=${onSubmit}>
@@ -234,6 +236,8 @@ export function LoginPage({ onLogin }) {
<a href="#/reset">${t('login.forgot')}</a>
</div>
</div>
+ ${!platform.isNative && html`<${WelcomeLinks} />`}
+ </div>
${!platform.isNative && html`<${WelcomePitch} />`}
</div>
</div>
@@ -244,20 +248,49 @@ export function LoginPage({ onLogin }) {
// application the reader has already downloaded it, and the links point at the
// project's own site rather than at whichever hub the application is set to.
//
-// Every sentence here is held to MESHBAY_DESIGN.md §2.3. "Data never transits
-// the hub" is a claim the design makes; "the hub cannot read anything" is one it
-// forbids (T3), which is why the list below says what never *reaches* the hub
-// and stops there. "End-to-end" means device to node, as §2.3 defines it.
+// Written for somebody who is not in IT: what it does for them first, how it
+// works in three steps, and privacy said once, plainly. Every sentence is held
+// to MESHBAY_DESIGN.md §2.3 — "your content never passes through meshbay.org"
+// is a claim the design makes; "meshbay.org cannot read anything" is one it
+// forbids (T3). "Encrypted all the way" means device to node, as §2.3 defines.
const WELCOME_APPS = [
['chat', 'welcome.app_chat'], ['image', 'welcome.app_photos'],
['video', 'welcome.app_media'], ['play', 'welcome.app_video'],
['music', 'welcome.app_music'],
];
+const WELCOME_STEPS = [
+ ['home', 'welcome.step_home'], ['globe', 'welcome.step_anywhere'],
+ ['user', 'welcome.step_share'],
+];
const WELCOME_USES = [
['chat', 'welcome.use_chat'], ['image', 'welcome.use_photos'],
['cast', 'welcome.use_media'], ['pencil', 'welcome.use_apps'],
];
-const WELCOME_NEVER = ['welcome.never_transit', 'welcome.never_stored', 'welcome.never_e2e'];
+const WELCOME_BADGES = ['welcome.badge_free', 'welcome.badge_open',
+ 'welcome.badge_no_ads', 'welcome.badge_no_tracking'];
+
+const REPO = 'https://git.meshbay.org/meshbay.git/about/';
+const WELCOME_DOCS = [
+ ['folder', 'welcome.docs_source', [['welcome.docs_repo', REPO]]],
+ ['user', 'welcome.docs_user', [
+ ['welcome.docs_quickstart', `${REPO}docs/QUICKSTART.md`],
+ ['welcome.docs_userguide', `${REPO}docs/USERGUIDE.md`]]],
+ ['gear', 'welcome.docs_devel', [
+ ['welcome.docs_design', `${REPO}docs/MESHBAY_DESIGN.md`],
+ ['welcome.docs_protocol', `${REPO}docs/MESHBAY_NODE_PROTOCOL.md`]]],
+];
+
+// Under the sign-in form rather than at the foot of the text: on a desktop the
+// text column runs far below the form, and these were the last thing on it.
+function WelcomeLinks() {
+ return html`
+ <div class="welcome-links">
+ <a class="welcome-cta" href="https://meshbay.org/downloads/">
+ <${Icon} name="download" />${t('welcome.download')}</a>
+ <a class="welcome-legal" href="https://meshbay.org/legal/">${t('welcome.legal')}</a>
+ </div>
+ `;
+}
function WelcomePitch() {
return html`
@@ -268,8 +301,13 @@ function WelcomePitch() {
${WELCOME_APPS.map(([icon, key]) => html`
<li key=${key}><${Icon} name=${icon} />${t(key)}</li>`)}
</ul>
- <p>${t('welcome.groups')}</p>
- <p class="welcome-e2e"><${Icon} name="lock" /><span>${t('welcome.e2e')}</span></p>
+
+ <h2 class="welcome-h">${t('welcome.how_title')}</h2>
+ <ol class="welcome-steps">
+ ${WELCOME_STEPS.map(([icon, key]) => html`
+ <li key=${key}><span class="welcome-step-icon"><${Icon} name=${icon} /></span>
+ <span>${t(key)}</span></li>`)}
+ </ol>
<h2 class="welcome-h">${t('welcome.uses_title')}</h2>
<ul class="welcome-uses">
@@ -278,26 +316,51 @@ function WelcomePitch() {
<span>${t(key)}</span></li>`)}
</ul>
- <div class="welcome-hub">
- <h2 class="welcome-h">${t('welcome.hub_title')}</h2>
- <p>${t('welcome.hub_body')}</p>
- <p class="welcome-hub-never">${t('welcome.hub_never')}</p>
- <ul class="welcome-never">
- ${WELCOME_NEVER.map(key => html`
- <li key=${key}><${Icon} name="check" /><span>${t(key)}</span></li>`)}
- </ul>
- <p class="welcome-hub-free">${t('welcome.hub_free')}</p>
+ <div class="welcome-private">
+ <span class="welcome-private-icon"><${Icon} name="shield" /></span>
+ <div>
+ <h2 class="welcome-private-title">${t('welcome.private_title')}</h2>
+ <p>${t('welcome.private_body')}</p>
+ <ul class="welcome-badges">
+ ${WELCOME_BADGES.map(key => html`
+ <li key=${key}><${Icon} name="check" />${t(key)}</li>`)}
+ </ul>
+ </div>
</div>
- <div class="welcome-links">
- <a class="welcome-cta" href="https://meshbay.org/downloads/">
- <${Icon} name="download" />${t('welcome.download')}</a>
- <a class="welcome-legal" href="https://meshbay.org/legal/">${t('welcome.legal')}</a>
+ <div class="welcome-docs">
+ <h2 class="welcome-h">${t('welcome.docs_title')}</h2>
+ <ul>
+ ${WELCOME_DOCS.map(([icon, label, links]) => html`
+ <li key=${label}><${Icon} name=${icon} />
+ <span class="welcome-docs-label">${t(label)}</span>
+ <span class="welcome-docs-links">${links.map(([key, href], i) => html`
+ ${i > 0 && ' · '}<a key=${key} href=${href} target="_blank"
+ rel="noopener noreferrer">${t(key)}</a>`)}</span></li>`)}
+ </ul>
</div>
+
</section>
`;
}
+// The sign-in page's dark gradient backdrop and frosted card, without the
+// pitch beside it — so Register (and its verify/recovery/done steps) sits on
+// the same background and reads in the same dark theme as Login. A lone
+// `.welcome-side` is centred by `.welcome`'s `justify-content`.
+function AuthShell({ children }) {
+ return html`
+ <div class="page-center">
+ <div class="welcome-backdrop" aria-hidden="true"></div>
+ <div class="welcome">
+ <div class="welcome-side">
+ ${children}
+ </div>
+ </div>
+ </div>
+ `;
+}
+
export function RegisterPage() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
@@ -312,7 +375,9 @@ export function RegisterPage() {
const [recoveryMnemonic, setRecoveryMnemonic] = useState('');
const [recoverySaved, setRecoverySaved] = useState(false);
const [recoveryCopied, setRecoveryCopied] = useState(false);
- const [emailRecovery, setEmailRecovery] = useState(true);
+ // Off by default: mailing the recovery key is opt-in — the key is shown on
+ // screen to save, and sending a copy is the user's own choice to make.
+ const [emailRecovery, setEmailRecovery] = useState(false);
const captcha = useCaptcha();
const onSubmit = async (e) => {
@@ -411,7 +476,7 @@ export function RegisterPage() {
if (phase === 'done') {
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.verified_title')}</h2>
<p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
@@ -421,7 +486,7 @@ export function RegisterPage() {
<p style="text-align:center; margin-bottom:16px">${t('invite.after_register')}</p>`}
<a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a>
</div>
- </div>
+ </${AuthShell}>
`;
}
@@ -434,7 +499,7 @@ export function RegisterPage() {
} catch { /* clipboard blocked — the text is on screen to copy by hand */ }
};
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.recovery_title')}</h2>
<p style="margin-bottom:12px; color:var(--text-secondary)">
@@ -463,13 +528,13 @@ export function RegisterPage() {
${t('register.recovery_continue')}
</button>
</div>
- </div>
+ </${AuthShell}>
`;
}
if (phase === 'verify') {
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.success_title')}</h2>
<p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
@@ -492,12 +557,12 @@ export function RegisterPage() {
${t('register.resend_sent')}</span>`}
</div>
</div>
- </div>
+ </${AuthShell}>
`;
}
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.title')}</h2>
<form onSubmit=${onSubmit}>
@@ -539,7 +604,7 @@ export function RegisterPage() {
${t('register.has_account')} <a href="#/login">${t('register.login_link')}</a>
</div>
</div>
- </div>
+ </${AuthShell}>
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
index aac8225..15d352a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
@@ -78,7 +78,7 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user
const ack = await Promise.race([
transport.connect(
n.node_id, live, groupId, null, null, bundleKey,
- username, userId, null),
+ username, userId, null, undefined, undefined, n.pk_node),
new Promise((_, reject) => {
const giveUp = () => reject(new Error('Connection timeout'));
stallTimer = setTimeout(giveUp, SEARCH_STALL_MS);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
index f81247d..7556010 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
@@ -124,7 +124,7 @@ function CreateGroupFormSimple({ token, onCreated, allowPublicGroups = true }) {
`;
}
-function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = true }) {
+function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPublicGroups = true }) {
const [step, setStep] = useState(0);
const [nodeStatus, setNodeStatus] = useState(null);
const [nodeStarting, setNodeStarting] = useState(false);
@@ -142,12 +142,19 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
const linkNodeKey = useCallback(async (pk) => {
if (!pk) return;
- try {
- await hubFetch('/v1/users/me/node_key', {
- method: 'PUT', token, body: { pk_node_ed25519: pk },
- });
- } catch { /* already linked or same key */ }
- }, [token]);
+ // `PUT /me/node_key` is idempotent — linking the same key again returns 200,
+ // so there is no "already linked" case to swallow here. A failure means the
+ // hub did not record this node's key (a rejected session, a malformed key),
+ // and the node then fails to authenticate and never comes up. It must
+ // surface — `detectNode`'s catch shows it — rather than let the wizard
+ // proceed against a node that looks linked but is not.
+ await hubFetch('/v1/users/me/node_key', {
+ method: 'PUT', token, body: { pk_node_ed25519: pk },
+ });
+ // The sidebar's Node section depends on this link; it only re-read it at
+ // sign-in, so the first node set up here stayed out of it until a reload.
+ if (onNodeLinked) onNodeLinked();
+ }, [token, onNodeLinked]);
const detectNode = useCallback(async () => {
setNodeStatus(null);
@@ -173,6 +180,8 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
setError('');
try {
const result = await platform.node.start({ hubUrl: HUB, username, token });
+ // node.start links the key from the main process, out of this page's sight.
+ if (onNodeLinked) onNodeLinked();
setNodeStatus({ detected: true, ...result });
setNodeStarting(false);
setStep(1);
@@ -333,6 +342,7 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
&& !provisionAttempted.current
&& (nodeStatus.status === 'waiting_for_account'
|| nodeStatus.status === 'waiting_for_node_key'
+ || nodeStatus.status === 'waiting_for_hub'
|| nodeStatus.status === 'starting')) {
provisionAttempted.current = true;
startNode();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/favicon.ico b/packages/meshbay-hub/src/meshbay_hub/static/favicon.ico
new file mode 100644
index 0000000..c24a117
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/favicon.ico
Binary files differ
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 2ee6e05..7c9b2f0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -16,8 +16,9 @@ import { FilePreview } from './files-app.js';
import { lazy } from './lazy.js';
// Fetched the first time a video is played / the Settings tab is opened.
+const playerFrame = (content) => html`<div class="video-overlay video-player-overlay">${content}</div>`;
const VideoPlayer = lazy(() => import('./video-player.js'), 'VideoPlayer',
- html`<div class="video-overlay"><p class="page-message"><span class="spinner"></span></p></div>`);
+ playerFrame(html`<p class="page-message"><span class="spinner"></span></p>`), playerFrame);
const GroupSettingsPanel = lazy(() => import('./group-settings.js'), 'GroupSettingsPanel');
import { reportIndexPush } from './index-dock.js';
import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js';
@@ -433,7 +434,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
try {
ack = await transport.connect(
n.node_id, live, groupId, null, sessionKeys, session.bundleKey,
- username, userId, joinCode, session.recoveryKey, joinNodePk);
+ username, userId, joinCode, session.recoveryKey, joinNodePk, n.pk_node);
break;
} catch (e) {
lastErr = e;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 529a33d..fba8a0e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -417,7 +417,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// Whether the hub mails the invitation. Checked, the hub is handed the code
// to write it into the mail — so it is the inviter's choice, remembered per
// account (docs/MESHBAY_DESIGN.md §3.4). Unchecked, the hub never sees it.
- const [inviteByEmail, setInviteByEmail] = useState(true);
+ // Off by default: mailing the code is opt-in, not something to do unasked.
+ const [inviteByEmail, setInviteByEmail] = useState(false);
const [error, setError] = useState('');
// Node loopback state (Electron-only)
@@ -811,7 +812,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
useEffect(() => {
hubFetch('/v1/users/me/preferences', { token })
- .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] !== 'false'))
+ .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] === 'true'))
.catch(() => {});
}, [token]);
@@ -897,10 +898,11 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// ── Invitation links (docs/MESHBAY_DESIGN.md §3.4) ──────────────────
//
// Two halves, in the order that leaves nothing half-made: the node's code
- // first, then the hub's ticket bound to the address; a ticket the hub then
- // refuses takes the code back with it, since a code nobody can reach the node
- // with only occupies one of the group's twenty places. The code reaches the
- // hub only when the box asks the hub to write the mail.
+ // first, then the hub's ticket; a ticket the hub then refuses takes the code
+ // back with it, since a code nobody can reach the node with only occupies one
+ // of the group's twenty places. The address is optional and binds nothing:
+ // the link is for whoever opens it first, so it can go by any messaging app.
+ // The code reaches the hub only when the box asks the hub to write the mail.
const [linkEmail, setLinkEmail] = useState('');
const [linking, setLinking] = useState(false);
const [linkError, setLinkError] = useState('');
@@ -932,7 +934,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const doCreateLink = useCallback(async (e) => {
e.preventDefault();
const email = linkEmail.trim();
- if (!email) return;
+ const mailIt = inviteByEmail && Boolean(email);
setLinking(true);
setLinkError('');
setNewLink(null);
@@ -951,8 +953,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
method: 'POST', token,
body: {
email, expires_at: node.expires_at, node_invite_id: node.invite_id,
- send_email: inviteByEmail,
- ...(inviteByEmail ? { node_pk: n, code: node.code } : {}),
+ send_email: mailIt,
+ ...(mailIt ? { node_pk: n, code: node.code } : {}),
},
});
} catch (err) {
@@ -960,7 +962,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
throw err;
}
setNewLink({
- email,
link: inviteLinkHere({ g: groupId, t: ticket.ticket, n, c: node.code }),
emailStatus: ticket.email_status,
});
@@ -1004,6 +1005,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
} catch { /* the field is selectable */ }
}, [newLink]);
+ // The system share sheet, where there is one (phones mostly): the way a link
+ // reaches a messaging app without a round trip through the clipboard.
+ const canShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function';
+ const shareLink = useCallback(async () => {
+ if (!newLink) return;
+ try {
+ await navigator.share({ title: t('members.link_share_title'), url: newLink.link });
+ } catch { /* dismissed; the field and Copy are still there */ }
+ }, [newLink]);
+
if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`;
const isOwner = Boolean(isAdmin);
@@ -1049,7 +1060,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${inviting ? '...' : t('members.invite_btn')}
</button>
</div>
- <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0;
+ <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0;
font-size:0.88em; color:var(--text-secondary)">
<input type="checkbox" checked=${inviteByEmail}
onChange=${e => toggleInviteByEmail(e.target.checked)} />
@@ -1067,13 +1078,17 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<form onSubmit=${doCreateLink}>
${newLink && html`
<div class="success-msg" style="margin-bottom:8px">
- <p>${t('members.link_ready', { email: newLink.email })}</p>
+ <p>${t('members.link_ready')}</p>
<div class="form-row">
<input type="text" readonly value=${newLink.link}
onFocus=${e => e.target.select()} />
<button class="admin-btn" type="button" onClick=${copyLink}>
${linkCopied ? t('members.link_copied') : t('members.link_copy')}
</button>
+ ${canShare && html`
+ <button class="admin-btn" type="button" onClick=${shareLink}>
+ ${t('members.link_share')}
+ </button>`}
</div>
${newLink.emailStatus === 'sent'
? html`<p style="color:var(--success)">${t('members.link_email_sent')}</p>`
@@ -1085,13 +1100,13 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<div class="form-row">
<input type="email" placeholder=${t('members.link_email_placeholder')}
value=${linkEmail} onInput=${e => setLinkEmail(e.target.value)}
- disabled=${!connected || !operatorPaired} required />
+ disabled=${!connected || !operatorPaired} />
<button class="admin-btn" type="submit"
disabled=${linking || !connected || !operatorPaired}>
${linking ? '...' : t('members.link_btn')}
</button>
</div>
- <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0;
+ <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0;
font-size:0.88em; color:var(--text-secondary)">
<input type="checkbox" checked=${inviteByEmail}
onChange=${e => toggleInviteByEmail(e.target.checked)} />
@@ -1104,7 +1119,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${links.map(l => html`
<li key=${l.link_id} style="display:flex;gap:8px;align-items:center;
flex-wrap:wrap;word-break:break-word;margin:4px 0">
- <span>${l.email}</span>
+ <span>${l.email || t('members.link_unlabelled',
+ { date: new Date(l.created_at).toLocaleDateString() })}</span>
<span style="color:var(--text-dim)">
${l.status === 'expired'
? t('members.link_status_expired')
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js
index dd678aa..41b4c4c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js
@@ -25,10 +25,7 @@ export function InvitePage({ user, onJoined }) {
const [error, setError] = useState('');
const refused = useCallback((err) => {
- if (err.message === 'invite_other_account') {
- // Kept: signing out and in again as the right account is the fix.
- setPhase('other');
- } else if (err.message === 'invite_not_valid') {
+ if (err.message === 'invite_not_valid') {
clearPending();
setPhase('invalid');
} else {
@@ -97,12 +94,6 @@ export function InvitePage({ user, onJoined }) {
<button class="btn btn-primary" onClick=${join}>${t('invite.join')}</button>
<button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button>
</div>`;
- } else if (phase === 'other') {
- body = html`
- <p class="error-msg">${t('invite.other_account')}</p>
- <div style="margin-top:16px">
- <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button>
- </div>`;
} else if (phase === 'invalid') {
body = html`<p class="error-msg">${t('invite.invalid')}</p>`;
} else {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/lazy.js b/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
index 30a564c..fda73c0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
@@ -9,17 +9,45 @@
//
// A failed fetch (a network drop on a phone) is not remembered: the next time
// the component is shown it asks again, rather than failing for the session.
+//
+// A failed fetch is also *said*, never left as the placeholder's spinner. The
+// commonest failure is not the network: the hub serves the module graph under
+// `/a/<hash>/` for the current hash only, so a tab opened before a hub deploy
+// asks for every module it has not loaded yet under a prefix that now answers
+// 404. Such a tab kept Files and Chat (already loaded) and spun for ever on
+// Search, Videos, Music, Photos and the player — with nothing in the console,
+// because the rejection was swallowed here. Asking again cannot help that tab;
+// reloading the page is the one thing that does, so that is what is offered.
+// Not done automatically: a reload that fails the same way would loop, and it
+// would throw away whatever the reader had in progress.
import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
const SPINNER = html`<p class="page-message"><span class="spinner"></span></p>`;
-function lazy(load, name, placeholder = SPINNER) {
+function LoadFailed({ onClose }) {
+ return html`
+ <div class="page-message lazy-failed" role="alert">
+ <p>${t('lazy.load_failed')}</p>
+ <button class="admin-btn" onClick=${() => location.reload()}>${t('lazy.reload')}</button>
+ ${onClose && html`
+ ${' '}<button class="admin-btn" onClick=${onClose}>${t('admin.btn_close')}</button>
+ `}
+ </div>
+ `;
+}
+
+// `frame` wraps the failure notice, so a component that is an overlay (the
+// video player) can put it where the overlay would have been rather than
+// somewhere at the bottom of the page, under nothing anyone is looking at.
+function lazy(load, name, placeholder = SPINNER, frame = (content) => content) {
let Loaded = null;
let pending = null;
function Lazy(props) {
const [, setReady] = useState(Loaded !== null);
+ const [failed, setFailed] = useState(false);
useEffect(() => {
if (Loaded) return undefined;
let alive = true;
@@ -28,11 +56,15 @@ function lazy(load, name, placeholder = SPINNER) {
(m) => { Loaded = m[name]; },
(e) => { pending = null; throw e; });
}
- pending.then(() => { if (alive) setReady(true); }, () => {});
+ pending.then(() => { if (alive) setReady(true); }, (e) => {
+ console.error(`[MeshBay] could not load ${name}:`, e);
+ if (alive) setFailed(true);
+ });
return () => { alive = false; };
}, []);
- if (!Loaded) return placeholder;
- return html`<${Loaded} ...${props} />`;
+ if (Loaded) return html`<${Loaded} ...${props} />`;
+ if (failed) return frame(html`<${LoadFailed} onClose=${props.onClose} />`);
+ return placeholder;
}
Lazy.displayName = `Lazy(${name})`;
return Lazy;
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 7008f79..36a9423 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -93,26 +93,36 @@ export default {
'login.locked': "Zu viele falsche Passphrasen für dieses Konto. Versuchen Sie es in {minutes} Min. erneut oder setzen Sie Ihre Passphrase zurück.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Das Internet, wie es gedacht war.',
- 'welcome.lead': 'MeshBay ist Open-Source-Software, mit der Sie aus der Ferne auf Ihre persönlichen Dateien zugreifen und Anwendungen direkt auf dem Speicher Ihres eigenen Computers betreiben:',
+ 'welcome.lead': 'Ihre Fotos, Musik, Videos und Unterhaltungen bleiben zu Hause, auf Ihrem eigenen Computer. Mit MeshBay genießen Sie sie von überall und teilen sie mit den Menschen Ihrer Wahl.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Fotos',
'welcome.app_media': 'Mediacenter',
'welcome.app_video': 'Videoplayer',
'welcome.app_music': 'Musikplayer',
- 'welcome.groups': 'Erstellen Sie Gruppen, um Ihren Liebsten Zugriff auf einige dieser Anwendungen zu geben.',
- 'welcome.e2e': 'Ihre Daten sind Ende-zu-Ende-verschlüsselt und gehen Peer-to-Peer direkt von jedem Gerät zu dem Computer, auf dem sie liegen (Ihrem Node). Sie laufen niemals über den Hub meshbay.org.',
+ 'welcome.how_title': 'So funktioniert es',
+ 'welcome.step_home': 'Installieren Sie MeshBay auf einem Computer zu Hause. Ihre Dateien bleiben, wo sie sind.',
+ 'welcome.step_anywhere': 'Melden Sie sich von Ihrem Handy, Laptop oder jedem Browser aus an, wo immer Sie sind.',
+ 'welcome.step_share': 'Erstellen Sie Gruppen und laden Sie Familie und Freunde ein.',
'welcome.uses_title': 'Was Sie damit tun können',
'welcome.use_chat': 'Sofortnachrichten innerhalb einer Gruppe',
'welcome.use_photos': 'Fotoalben mit Familie und Freunden teilen',
'welcome.use_media': 'Von überall auf Ihr Mediacenter zu Hause zugreifen, für Sie und Ihren Haushalt (mit Streaming und Chromecast über die Desktop-App)',
'welcome.use_apps': 'Eigene Anwendungen auf dem Node entwickeln, den Sie betreiben',
- 'welcome.hub_title': 'Was meshbay.org tut',
- 'welcome.hub_body': 'meshbay.org ist lediglich ein Signalisierungsdienst: Er meldet Sie an und verbindet die Beteiligten miteinander (Plug-and-Play, über Heimnetzwerke hinweg).',
- 'welcome.hub_never': 'Ihre Inhalte erreichen ihn nie:',
- 'welcome.never_transit': 'Nichts läuft über ihn: keine Inhalte, keine Indexierung',
- 'welcome.never_stored': 'Keine Ihrer Dateien oder Nachrichten wird dort gespeichert',
- 'welcome.never_e2e': 'Durchgehende Verschlüsselung, von jedem Gerät bis zu Ihrem Node',
- 'welcome.hub_free': 'Der Dienst ist kostenlos. Es gibt bewusst kein Geschäftsmodell, kein Tracking und keine Werbung. Viel Spaß!',
+ 'welcome.private_title': 'Privat von Grund auf',
+ 'welcome.private_body': 'Ihre Dateien und Nachrichten reisen verschlüsselt, direkt von Ihren Geräten zu Ihrem eigenen Computer. meshbay.org hilft Ihren Geräten nur, sich zu finden: Ihre Inhalte laufen nie darüber.',
+ 'welcome.badge_free': 'Kostenlos',
+ 'welcome.badge_open': 'Open Source',
+ 'welcome.badge_no_ads': 'Keine Werbung',
+ 'welcome.badge_no_tracking': 'Kein Tracking',
+ 'welcome.docs_title': 'Dokumentation',
+ 'welcome.docs_source': 'Quellcode',
+ 'welcome.docs_repo': 'git.meshbay.org',
+ 'welcome.docs_user': 'Benutzerdoku',
+ 'welcome.docs_quickstart': 'Schnellstart',
+ 'welcome.docs_userguide': 'Benutzerhandbuch',
+ 'welcome.docs_devel': 'Entwicklerdoku',
+ 'welcome.docs_design': 'Architektur',
+ 'welcome.docs_protocol': 'Protokoll',
'welcome.download': 'Herunterladen (Beta)',
'welcome.legal': 'Rechtliche Hinweise',
'register.err_mismatch': 'Die Passwörter stimmen nicht überein',
@@ -688,10 +698,10 @@ export default {
'members.invite_email_failed': 'Die E-Mail konnte nicht gesendet werden — bitte teilen Sie den Code manuell mit.',
'members.invite_email_opt': 'Einladung per E-Mail senden (kann im Spam landen)',
'members.link_title': "Per Link einladen",
- 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Der Link funktioniert einmal und nur für ein Konto mit dieser Adresse.",
- 'members.link_email_placeholder': "E-Mail-Adresse",
+ 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Verschicken Sie ihn, wie Sie möchten — Messenger, SMS. Der Link funktioniert einmal, sieben Tage lang, für die Person, die ihn zuerst öffnet.",
+ 'members.link_email_placeholder': "E-Mail-Adresse (optional)",
'members.link_btn': "Link erstellen",
- 'members.link_ready': "Einladungslink für {email}:",
+ 'members.link_ready': "Einladungslink — 7 Tage gültig, einmalig verwendbar:",
'members.link_copy': "Kopieren",
'members.link_copied': "Kopiert",
'members.link_email_sent': "Der Link wurde per E-Mail gesendet.",
@@ -700,9 +710,12 @@ export default {
'members.link_status_expired': "abgelaufen",
'members.link_expires': "läuft ab am {date}",
'members.link_cancel': "Abbrechen",
+ 'members.link_share': "Teilen",
+ 'members.link_share_title': "Einladung",
+ 'members.link_unlabelled': "Link vom {date}",
'invite.title': "Sie wurden eingeladen",
'invite.none': "In diesem Tab wartet keine Einladung. Öffnen Sie den erhaltenen Link erneut.",
- 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto mit der E-Mail-Adresse, an die die Einladung ging, oder melden Sie sich an, falls Sie bereits eines haben.",
+ 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto, oder melden Sie sich an, falls Sie bereits eines haben.",
'invite.register': "Konto erstellen",
'invite.signin': "Anmelden",
'invite.confirm': "{inviter} lädt Sie in {group} ein.",
@@ -710,7 +723,6 @@ export default {
'invite.ignore': "Ignorieren",
'invite.open': "Gruppe öffnen",
'invite.already_member': "Sie sind bereits Mitglied von {group}.",
- 'invite.other_account': "Diese Einladung wurde an eine andere E-Mail-Adresse gesendet. Melden Sie sich mit dem Konto dieser Adresse an — Aliasse und Punkte müssen genau übereinstimmen.",
'invite.invalid': "Diese Einladung ist nicht mehr gültig: Sie wurde verwendet, widerrufen oder ist abgelaufen. Bitten Sie um eine neue.",
'invite.joining': "Beitritt…",
'invite.after_register': "Melden Sie sich an, um der Gruppe beizutreten, in die Sie eingeladen wurden.",
@@ -876,6 +888,8 @@ export default {
// Group misc
'group.retry': 'Erneut versuchen',
+ 'lazy.load_failed': 'Dieser Teil der Oberfläche konnte nicht geladen werden. Der Hub wurde möglicherweise aktualisiert, seit diese Seite geöffnet wurde.',
+ 'lazy.reload': 'Seite neu laden',
// Sidebar
'sidebar.node': 'Node',
@@ -898,7 +912,7 @@ export default {
'node.service_restarting': 'Wird neu gestartet…',
'node.service_mode_hint': 'Läuft als Hintergrunddienst — startet beim Booten, vor der Anmeldung.',
'node.startup_mode_label': 'Automatisch starten:',
- 'node.startup_mode_off': 'Aus (manuell starten)',
+ 'node.startup_mode_off': 'Nur solange MeshBay geöffnet ist',
'node.startup_mode_signin': 'Bei der Anmeldung',
'node.startup_mode_service': 'Als Hintergrunddienst (startet beim Booten)',
'node.startup_mode_updating': 'Modus wird gewechselt — achten Sie auf eine Administrator-Eingabeaufforderung…',
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 1ea6eda..67cf5f4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -96,26 +96,36 @@ export default {
'login.locked': "Too many wrong passphrases for this account. Try again in {minutes} min, or reset your passphrase.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'The Internet as it was meant to be.',
- 'welcome.lead': 'MeshBay is open-source software that gives you remote access to your personal files, and runs applications on top of the storage on your own computer:',
+ 'welcome.lead': 'Your photos, music, videos and conversations stay at home, on your own computer. MeshBay lets you enjoy them from anywhere, and share them with the people you choose.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Photos',
'welcome.app_media': 'Media center',
'welcome.app_video': 'Video player',
'welcome.app_music': 'Music player',
- 'welcome.groups': 'Create groups to give the people close to you access to some of these applications.',
- 'welcome.e2e': 'Your data is end-to-end encrypted and travels peer to peer, from each device to the computer that hosts it (your node). It never passes through the meshbay.org hub.',
+ 'welcome.how_title': 'How it works',
+ 'welcome.step_home': 'Install MeshBay on a computer at home. Your files stay right where they are.',
+ 'welcome.step_anywhere': 'Sign in from your phone, your laptop or any browser, wherever you are.',
+ 'welcome.step_share': 'Create groups and invite your family and friends to join them.',
'welcome.uses_title': 'What you can do with it',
'welcome.use_chat': 'Instant messaging within a group',
'welcome.use_photos': 'Share photo albums with your family and friends',
'welcome.use_media': 'Reach your home media center from anywhere, for the whole household (streaming, plus Chromecast from the desktop app)',
'welcome.use_apps': 'Build your own applications on the node you host',
- 'welcome.hub_title': 'What meshbay.org does',
- 'welcome.hub_body': 'meshbay.org is only a signaling service: it signs you in and connects the parties to one another, plug-and-play, across home networks.',
- 'welcome.hub_never': 'Your content never reaches it:',
- 'welcome.never_transit': 'Nothing passes through it: no content, no indexing',
- 'welcome.never_stored': 'None of your files or messages are stored on it',
- 'welcome.never_e2e': 'Encryption runs end to end, from each device to your node',
- 'welcome.hub_free': 'The service is free. There is no business model by design, no tracking and no ads. Enjoy!',
+ 'welcome.private_title': 'Private by design',
+ 'welcome.private_body': 'Your files and messages travel encrypted, straight from your devices to your own computer. meshbay.org simply helps your devices find each other: your content never passes through it.',
+ 'welcome.badge_free': 'Free',
+ 'welcome.badge_open': 'Open source',
+ 'welcome.badge_no_ads': 'No ads',
+ 'welcome.badge_no_tracking': 'No tracking',
+ 'welcome.docs_title': 'Documentation',
+ 'welcome.docs_source': 'Source code',
+ 'welcome.docs_repo': 'git.meshbay.org',
+ 'welcome.docs_user': 'User docs',
+ 'welcome.docs_quickstart': 'Quick start',
+ 'welcome.docs_userguide': 'User guide',
+ 'welcome.docs_devel': 'Developer docs',
+ 'welcome.docs_design': 'Design',
+ 'welcome.docs_protocol': 'Protocol',
'welcome.download': 'Download (beta)',
'welcome.legal': 'Legal information',
'register.err_mismatch': 'Passwords do not match',
@@ -804,10 +814,10 @@ export default {
'members.invite_email_failed': 'Could not send the email — share the code manually.',
'members.invite_email_opt': 'Send the invitation by e-mail (may land in spam)',
'members.link_title': "Invite by link",
- 'members.link_hint': "For someone who may not have an account yet. The link works once, and only for an account registered with this address.",
- 'members.link_email_placeholder': "E-mail address",
+ 'members.link_hint': "For someone who may not have an account yet. Send it however you like — a messaging app, a text. It works once, for seven days, for whoever opens it first.",
+ 'members.link_email_placeholder': "E-mail address (optional)",
'members.link_btn': "Create link",
- 'members.link_ready': "Invitation link for {email}:",
+ 'members.link_ready': "Invitation link — valid 7 days, single use:",
'members.link_copy': "Copy",
'members.link_copied': "Copied",
'members.link_email_sent': "The link has been sent by e-mail.",
@@ -816,9 +826,12 @@ export default {
'members.link_status_expired': "expired",
'members.link_expires': "expires {date}",
'members.link_cancel': "Cancel",
+ 'members.link_share': "Share",
+ 'members.link_share_title': "Invitation",
+ 'members.link_unlabelled': "link created {date}",
'invite.title': "You have been invited",
'invite.none': "There is no invitation waiting in this tab. Open the link you received again.",
- 'invite.signed_out': "Someone invited you to a group on this hub. Create an account with the e-mail address the invitation was sent to, or sign in if you already have one.",
+ 'invite.signed_out': "Someone invited you to a group on this hub. Create an account, or sign in if you already have one.",
'invite.register': "Create an account",
'invite.signin': "Sign in",
'invite.confirm': "{inviter} invites you to join {group}.",
@@ -826,7 +839,6 @@ export default {
'invite.ignore': "Ignore",
'invite.open': "Open the group",
'invite.already_member': "You are already a member of {group}.",
- 'invite.other_account': "This invitation was sent to another e-mail address. Sign in with the account registered with that address — aliases and dots must match exactly.",
'invite.invalid': "This invitation is no longer valid: it has been used, cancelled or has expired. Ask for a new one.",
'invite.joining': "Joining…",
'invite.after_register': "Sign in to join the group you were invited to.",
@@ -972,6 +984,8 @@ export default {
'group.mute': 'Mute notifications',
'group.unmute': 'Unmute notifications',
'group.retry': 'Retry',
+ 'lazy.load_failed': 'This part of the interface could not be loaded. The hub may have been updated since this page was opened.',
+ 'lazy.reload': 'Reload the page',
'profile.title': 'Profile',
'usermenu.profile': 'Profile',
@@ -994,7 +1008,7 @@ export default {
'node.service_restarting': 'Restarting…',
'node.service_mode_hint': 'Running as a background service — it starts at boot, before sign-in.',
'node.startup_mode_label': 'Start automatically:',
- 'node.startup_mode_off': 'Off (start manually)',
+ 'node.startup_mode_off': 'Only while MeshBay is open',
'node.startup_mode_signin': 'At sign-in',
'node.startup_mode_service': 'As a background service (starts at boot)',
'node.startup_mode_updating': 'Switching mode — check for an administrator prompt…',
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 df65367..1e5f32f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -92,26 +92,36 @@ export default {
'login.locked': "Demasiadas frases de contraseña incorrectas para esta cuenta. Vuelva a intentarlo en {minutes} min o restablezca su frase de contraseña.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Internet como debió ser.',
- 'welcome.lead': 'MeshBay es un software de código abierto que le da acceso remoto a sus archivos personales y ejecuta aplicaciones sobre el almacenamiento de su propio ordenador:',
+ 'welcome.lead': 'Sus fotos, su música, sus vídeos y sus conversaciones se quedan en casa, en su propio ordenador. MeshBay le permite disfrutarlos desde cualquier lugar y compartirlos con las personas que usted elija.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Fotos',
'welcome.app_media': 'Centro multimedia',
'welcome.app_video': 'Reproductor de vídeo',
'welcome.app_music': 'Reproductor de música',
- 'welcome.groups': 'Cree grupos para dar a sus allegados acceso a algunas de estas aplicaciones.',
- 'welcome.e2e': 'Sus datos están cifrados de extremo a extremo y viajan entre pares, directamente de cada dispositivo al ordenador que los aloja: su nodo. Nunca pasan por el hub meshbay.org.',
+ 'welcome.how_title': 'Cómo funciona',
+ 'welcome.step_home': 'Instale MeshBay en un ordenador de casa. Sus archivos se quedan donde están.',
+ 'welcome.step_anywhere': 'Inicie sesión desde su móvil, su portátil o cualquier navegador, esté donde esté.',
+ 'welcome.step_share': 'Cree grupos e invite a su familia y a sus amigos a unirse.',
'welcome.uses_title': 'Qué puede hacer con él',
'welcome.use_chat': 'Mensajería instantánea dentro de un grupo',
'welcome.use_photos': 'Compartir álbumes de fotos con su familia y amigos',
'welcome.use_media': 'Acceder desde cualquier lugar a su centro multimedia, para usted y su hogar, con streaming y Chromecast desde la aplicación de escritorio',
'welcome.use_apps': 'Crear sus propias aplicaciones en el nodo que aloja',
- 'welcome.hub_title': 'Qué hace meshbay.org',
- 'welcome.hub_body': 'meshbay.org es solo un servicio de señalización: le autentica y conecta a las partes entre sí, sin configuración, a través de redes domésticas.',
- 'welcome.hub_never': 'Su contenido nunca llega a él:',
- 'welcome.never_transit': 'Nada pasa por él: ni contenido ni indexación',
- 'welcome.never_stored': 'Ninguno de sus archivos o mensajes se almacena en él',
- 'welcome.never_e2e': 'Cifrado de extremo a extremo, de cada dispositivo a su nodo',
- 'welcome.hub_free': 'El servicio es gratuito. Deliberadamente no hay modelo de negocio, ni rastreo, ni publicidad. ¡Disfrútelo!',
+ 'welcome.private_title': 'Privado desde el diseño',
+ 'welcome.private_body': 'Sus archivos y mensajes viajan cifrados, directamente de sus dispositivos a su propio ordenador. meshbay.org solo ayuda a que sus dispositivos se encuentren: su contenido nunca pasa por él.',
+ 'welcome.badge_free': 'Gratis',
+ 'welcome.badge_open': 'Código abierto',
+ 'welcome.badge_no_ads': 'Sin publicidad',
+ 'welcome.badge_no_tracking': 'Sin rastreo',
+ 'welcome.docs_title': 'Documentación',
+ 'welcome.docs_source': 'Código fuente',
+ 'welcome.docs_repo': 'git.meshbay.org',
+ 'welcome.docs_user': 'Docs de usuario',
+ 'welcome.docs_quickstart': 'Inicio rápido',
+ 'welcome.docs_userguide': 'Guía de usuario',
+ 'welcome.docs_devel': 'Docs de desarrollo',
+ 'welcome.docs_design': 'Diseño',
+ 'welcome.docs_protocol': 'Protocolo',
'welcome.download': 'Descargar (beta)',
'welcome.legal': 'Información legal',
'register.err_mismatch': 'Las contraseñas no coinciden',
@@ -683,10 +693,10 @@ export default {
'members.invite_email_failed': 'No se pudo enviar el correo — comparta el código manualmente.',
'members.invite_email_opt': 'Enviar la invitación por correo (puede llegar a spam)',
'members.link_title': "Invitar con un enlace",
- 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. El enlace sirve una vez, y solo para una cuenta registrada con esta dirección.",
- 'members.link_email_placeholder': "Dirección de correo",
+ 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. Envíelo como prefiera — mensajería, SMS. El enlace sirve una sola vez, durante siete días, para quien lo abra primero.",
+ 'members.link_email_placeholder': "Dirección de correo (opcional)",
'members.link_btn': "Crear enlace",
- 'members.link_ready': "Enlace de invitación para {email}:",
+ 'members.link_ready': "Enlace de invitación — válido 7 días, un solo uso:",
'members.link_copy': "Copiar",
'members.link_copied': "Copiado",
'members.link_email_sent': "El enlace se ha enviado por correo.",
@@ -695,9 +705,12 @@ export default {
'members.link_status_expired': "caducado",
'members.link_expires': "caduca el {date}",
'members.link_cancel': "Cancelar",
+ 'members.link_share': "Compartir",
+ 'members.link_share_title': "Invitación",
+ 'members.link_unlabelled': "enlace creado el {date}",
'invite.title': "Le han invitado",
'invite.none': "No hay ninguna invitación esperando en esta pestaña. Vuelva a abrir el enlace que recibió.",
- 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta con la dirección de correo a la que se envió la invitación, o inicie sesión si ya tiene una.",
+ 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta, o inicie sesión si ya tiene una.",
'invite.register': "Crear una cuenta",
'invite.signin': "Iniciar sesión",
'invite.confirm': "{inviter} le invita a unirse a {group}.",
@@ -705,7 +718,6 @@ export default {
'invite.ignore': "Ignorar",
'invite.open': "Abrir el grupo",
'invite.already_member': "Ya es miembro de {group}.",
- 'invite.other_account': "Esta invitación se envió a otra dirección de correo. Inicie sesión con la cuenta registrada con esa dirección — los alias y los puntos deben coincidir exactamente.",
'invite.invalid': "Esta invitación ya no es válida: se ha usado, cancelado o ha caducado. Pida una nueva.",
'invite.joining': "Uniéndose…",
'invite.after_register': "Inicie sesión para unirse al grupo al que le invitaron.",
@@ -870,6 +882,8 @@ export default {
// Group
'group.retry': 'Reintentar',
+ 'lazy.load_failed': 'No se pudo cargar esta parte de la interfaz. Es posible que el hub se haya actualizado desde que se abrió esta página.',
+ 'lazy.reload': 'Recargar la página',
// Sidebar
'sidebar.node': 'Node',
@@ -892,7 +906,7 @@ export default {
'node.service_restarting': 'Reiniciando…',
'node.service_mode_hint': 'Se ejecuta como servicio en segundo plano — se inicia al arrancar, antes de iniciar sesión.',
'node.startup_mode_label': 'Iniciar automáticamente:',
- 'node.startup_mode_off': 'Desactivado (iniciar manualmente)',
+ 'node.startup_mode_off': 'Solo mientras MeshBay está abierto',
'node.startup_mode_signin': 'Al iniciar sesión',
'node.startup_mode_service': 'Como servicio en segundo plano (se inicia al arrancar)',
'node.startup_mode_updating': 'Cambiando de modo — compruebe si aparece un aviso de administrador…',
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 e5d542a..f1b3da3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -92,26 +92,36 @@ export default {
'login.locked': "Trop de phrases secrètes erronées pour ce compte. Réessayez dans {minutes} min, ou réinitialisez votre phrase secrète.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'L’Internet tel qu’il aurait dû être.',
- 'welcome.lead': 'MeshBay est un logiciel open source qui vous donne accès à distance à vos fichiers personnels et fait tourner des applications sur le stockage de votre ordinateur :',
+ 'welcome.lead': 'Vos photos, votre musique, vos vidéos et vos conversations restent chez vous, sur votre propre ordinateur. MeshBay vous permet d’en profiter de partout, et de les partager avec les personnes de votre choix.',
'welcome.app_chat': 'Messagerie',
'welcome.app_photos': 'Photos',
'welcome.app_media': 'Médiathèque',
'welcome.app_video': 'Lecteur vidéo',
'welcome.app_music': 'Lecteur de musique',
- 'welcome.groups': 'Créez des groupes pour donner accès à certaines de ces applications à vos proches.',
- 'welcome.e2e': 'Vos données sont chiffrées de bout en bout et circulent en pair à pair, de chaque appareil jusqu’à l’ordinateur qui les héberge (votre nœud). Elles ne passent jamais par le hub meshbay.org.',
+ 'welcome.how_title': 'Comment ça marche',
+ 'welcome.step_home': 'Installez MeshBay sur un ordinateur à la maison. Vos fichiers restent là où ils sont.',
+ 'welcome.step_anywhere': 'Connectez-vous depuis votre téléphone, votre portable ou n’importe quel navigateur, où que vous soyez.',
+ 'welcome.step_share': 'Créez des groupes et invitez-y votre famille et vos amis.',
'welcome.uses_title': 'Ce que vous pouvez en faire',
'welcome.use_chat': 'Messagerie instantanée au sein d’un groupe',
'welcome.use_photos': 'Partager des albums photo avec votre famille et vos amis',
'welcome.use_media': 'Accéder à distance à votre médiathèque personnelle, pour vous et votre foyer (en streaming, et avec Chromecast depuis l’application de bureau)',
'welcome.use_apps': 'Créer vos propres applications sur le nœud que vous hébergez',
- 'welcome.hub_title': 'Le rôle de meshbay.org',
- 'welcome.hub_body': 'meshbay.org est un simple service de signalisation : il vous authentifie et met les participants en relation, sans configuration, à travers les réseaux domestiques.',
- 'welcome.hub_never': 'Vos contenus ne l’atteignent jamais :',
- 'welcome.never_transit': 'Rien ne transite par lui : aucun contenu, aucune indexation',
- 'welcome.never_stored': 'Aucun de vos fichiers ou messages n’y est stocké',
- 'welcome.never_e2e': 'Chiffrement de bout en bout, de chaque appareil jusqu’à votre nœud',
- 'welcome.hub_free': 'Le service est gratuit : aucun modèle économique (c’est voulu), aucun traçage, aucune publicité. Profitez-en !',
+ 'welcome.private_title': 'Privé par conception',
+ 'welcome.private_body': 'Vos fichiers et vos messages circulent chiffrés, directement de vos appareils jusqu’à votre propre ordinateur. meshbay.org aide simplement vos appareils à se trouver : vos contenus ne passent jamais par lui.',
+ 'welcome.badge_free': 'Gratuit',
+ 'welcome.badge_open': 'Open source',
+ 'welcome.badge_no_ads': 'Sans publicité',
+ 'welcome.badge_no_tracking': 'Sans traçage',
+ 'welcome.docs_title': 'Documentation',
+ 'welcome.docs_source': 'Code source',
+ 'welcome.docs_repo': 'git.meshbay.org',
+ 'welcome.docs_user': 'Docs utilisateur',
+ 'welcome.docs_quickstart': 'Démarrage rapide',
+ 'welcome.docs_userguide': 'Guide utilisateur',
+ 'welcome.docs_devel': 'Docs développeur',
+ 'welcome.docs_design': 'Conception',
+ 'welcome.docs_protocol': 'Protocole',
'welcome.download': 'Télécharger (bêta)',
'welcome.legal': 'Informations légales',
'register.err_mismatch': 'Les mots de passe ne correspondent pas',
@@ -686,10 +696,10 @@ export default {
'members.invite_email_failed': "Impossible d’envoyer l’e-mail — partagez le code manuellement.",
'members.invite_email_opt': 'Envoyer l’invitation par e-mail (risque d’arriver dans les spams)',
'members.link_title': "Inviter par lien",
- 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Le lien ne sert qu’une fois, et seulement pour un compte créé avec cette adresse.",
- 'members.link_email_placeholder': "Adresse e-mail",
+ 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Envoyez-le comme vous voulez — messagerie, SMS. Le lien ne sert qu’une fois, pendant sept jours, à la première personne qui l’ouvre.",
+ 'members.link_email_placeholder': "Adresse e-mail (facultative)",
'members.link_btn': "Créer le lien",
- 'members.link_ready': "Lien d’invitation pour {email} :",
+ 'members.link_ready': "Lien d’invitation — valable 7 jours, usage unique :",
'members.link_copy': "Copier",
'members.link_copied': "Copié",
'members.link_email_sent': "Le lien a été envoyé par e-mail.",
@@ -698,9 +708,12 @@ export default {
'members.link_status_expired': "expiré",
'members.link_expires': "expire le {date}",
'members.link_cancel': "Annuler",
+ 'members.link_share': "Partager",
+ 'members.link_share_title': "Invitation",
+ 'members.link_unlabelled': "lien créé le {date}",
'invite.title': "Vous êtes invité",
'invite.none': "Aucune invitation n’attend dans cet onglet. Rouvrez le lien que vous avez reçu.",
- 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte avec l’adresse e-mail à laquelle l’invitation a été envoyée, ou connectez-vous si vous en avez déjà un.",
+ 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte, ou connectez-vous si vous en avez déjà un.",
'invite.register': "Créer un compte",
'invite.signin': "Se connecter",
'invite.confirm': "{inviter} vous invite à rejoindre {group}.",
@@ -708,7 +721,6 @@ export default {
'invite.ignore': "Ignorer",
'invite.open': "Ouvrir le groupe",
'invite.already_member': "Vous êtes déjà membre de {group}.",
- 'invite.other_account': "Cette invitation a été envoyée à une autre adresse e-mail. Connectez-vous avec le compte créé avec cette adresse — les alias et les points doivent correspondre exactement.",
'invite.invalid': "Cette invitation n’est plus valable : elle a été utilisée, annulée ou a expiré. Demandez-en une nouvelle.",
'invite.joining': "Adhésion…",
'invite.after_register': "Connectez-vous pour rejoindre le groupe auquel vous avez été invité.",
@@ -873,6 +885,8 @@ export default {
// Group
'group.retry': 'Réessayer',
+ 'lazy.load_failed': 'Cette partie de l\'interface n\'a pas pu être chargée. Le hub a peut-être été mis à jour depuis l\'ouverture de cette page.',
+ 'lazy.reload': 'Recharger la page',
// Sidebar
'sidebar.node': 'Node',
@@ -895,7 +909,7 @@ export default {
'node.service_restarting': 'Redémarrage…',
'node.service_mode_hint': 'Fonctionne comme service en arrière-plan — démarre au boot, avant l\'ouverture de session.',
'node.startup_mode_label': 'Démarrer automatiquement :',
- 'node.startup_mode_off': 'Désactivé (démarrage manuel)',
+ 'node.startup_mode_off': 'Uniquement quand MeshBay est ouvert',
'node.startup_mode_signin': 'À l\'ouverture de session',
'node.startup_mode_service': 'Comme service en arrière-plan (démarre au boot)',
'node.startup_mode_updating': 'Changement de mode — vérifiez une invite d\'administrateur…',
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 e376550..4120b1d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -93,26 +93,36 @@ export default {
'login.locked': "Troppe passphrase errate per questo account. Riprovi tra {minutes} min oppure reimposti la passphrase.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Internet come doveva essere.',
- 'welcome.lead': 'MeshBay è un software open source che le permette di accedere da remoto ai suoi file personali e di usare applicazioni basate sullo spazio di archiviazione del suo computer:',
+ 'welcome.lead': 'Le sue foto, la sua musica, i suoi video e le sue conversazioni restano a casa, sul suo computer. MeshBay le permette di goderseli ovunque e di condividerli con le persone che sceglie.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Foto',
'welcome.app_media': 'Media center',
'welcome.app_video': 'Lettore video',
'welcome.app_music': 'Lettore musicale',
- 'welcome.groups': 'Crei gruppi per dare alle persone a lei care l’accesso ad alcune di queste applicazioni.',
- 'welcome.e2e': 'I suoi dati sono cifrati end-to-end e viaggiano peer-to-peer, direttamente da ogni dispositivo al computer che li ospita: il suo nodo. Non passano mai dall’hub meshbay.org.',
+ 'welcome.how_title': 'Come funziona',
+ 'welcome.step_home': 'Installi MeshBay su un computer di casa. I suoi file restano dove sono.',
+ 'welcome.step_anywhere': 'Acceda dal telefono, dal portatile o da qualsiasi browser, ovunque si trovi.',
+ 'welcome.step_share': 'Crei dei gruppi e inviti la sua famiglia e i suoi amici a unirsi.',
'welcome.uses_title': 'Cosa può farci',
'welcome.use_chat': 'Messaggistica istantanea all’interno di un gruppo',
'welcome.use_photos': 'Condividere album fotografici con famiglia e amici',
'welcome.use_media': 'Accedere ovunque al media center di casa, per lei e la sua famiglia, con streaming e Chromecast dall’app desktop',
'welcome.use_apps': 'Creare le proprie applicazioni sul nodo che ospita',
- 'welcome.hub_title': 'Cosa fa meshbay.org',
- 'welcome.hub_body': 'meshbay.org è solo un servizio di segnalazione: la autentica e mette in contatto le parti, plug-and-play, attraverso le reti domestiche.',
- 'welcome.hub_never': 'I suoi contenuti non lo raggiungono mai:',
- 'welcome.never_transit': 'Nulla passa da lì: nessun contenuto, nessuna indicizzazione',
- 'welcome.never_stored': 'Nessun suo file o messaggio vi è archiviato',
- 'welcome.never_e2e': 'Crittografia end-to-end, da ogni dispositivo al suo nodo',
- 'welcome.hub_free': 'Il servizio è gratuito. Nessun modello di business (per scelta), nessun tracciamento, nessuna pubblicità. Buon divertimento!',
+ 'welcome.private_title': 'Privato per natura',
+ 'welcome.private_body': 'I suoi file e i suoi messaggi viaggiano cifrati, direttamente dai suoi dispositivi al suo computer. meshbay.org aiuta solo i dispositivi a trovarsi: i suoi contenuti non vi passano mai.',
+ 'welcome.badge_free': 'Gratuito',
+ 'welcome.badge_open': 'Open source',
+ 'welcome.badge_no_ads': 'Niente pubblicità',
+ 'welcome.badge_no_tracking': 'Nessun tracciamento',
+ 'welcome.docs_title': 'Documentazione',
+ 'welcome.docs_source': 'Codice sorgente',
+ 'welcome.docs_repo': 'git.meshbay.org',
+ 'welcome.docs_user': 'Documentazione utente',
+ 'welcome.docs_quickstart': 'Avvio rapido',
+ 'welcome.docs_userguide': 'Guida utente',
+ 'welcome.docs_devel': 'Documentazione tecnica',
+ 'welcome.docs_design': 'Architettura',
+ 'welcome.docs_protocol': 'Protocollo',
'welcome.download': 'Scarica (beta)',
'welcome.legal': 'Note legali',
'register.err_mismatch': 'Le password non coincidono',
@@ -686,10 +696,10 @@ export default {
'members.invite_email_failed': "Impossibile inviare l'e-mail — condivida il codice manualmente.",
'members.invite_email_opt': 'Invia l’invito per e-mail (potrebbe finire nello spam)',
'members.link_title': "Invita tramite link",
- 'members.link_hint': "Per chi forse non ha ancora un account. Il link funziona una volta, e solo per un account registrato con questo indirizzo.",
- 'members.link_email_placeholder': "Indirizzo e-mail",
+ 'members.link_hint': "Per chi forse non ha ancora un account. Lo invii come preferisce — messaggistica, SMS. Il link funziona una sola volta, per sette giorni, per chi lo apre per primo.",
+ 'members.link_email_placeholder': "Indirizzo e-mail (facoltativo)",
'members.link_btn': "Crea link",
- 'members.link_ready': "Link d’invito per {email}:",
+ 'members.link_ready': "Link di invito — valido 7 giorni, uso singolo:",
'members.link_copy': "Copia",
'members.link_copied': "Copiato",
'members.link_email_sent': "Il link è stato inviato per e-mail.",
@@ -698,9 +708,12 @@ export default {
'members.link_status_expired': "scaduto",
'members.link_expires': "scade il {date}",
'members.link_cancel': "Annulla",
+ 'members.link_share': "Condividi",
+ 'members.link_share_title': "Invito",
+ 'members.link_unlabelled': "link creato il {date}",
'invite.title': "È stato invitato",
'invite.none': "Nessun invito in attesa in questa scheda. Riapra il link ricevuto.",
- 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account con l’indirizzo e-mail a cui è stato inviato l’invito, o acceda se ne ha già uno.",
+ 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account, o acceda se ne ha già uno.",
'invite.register': "Crea un account",
'invite.signin': "Accedi",
'invite.confirm': "{inviter} la invita a unirsi a {group}.",
@@ -708,7 +721,6 @@ export default {
'invite.ignore': "Ignora",
'invite.open': "Apri il gruppo",
'invite.already_member': "È già membro di {group}.",
- 'invite.other_account': "Questo invito è stato inviato a un altro indirizzo e-mail. Acceda con l’account registrato con quell’indirizzo — alias e punti devono corrispondere esattamente.",
'invite.invalid': "Questo invito non è più valido: è stato usato, annullato o è scaduto. Ne chieda uno nuovo.",
'invite.joining': "Adesione…",
'invite.after_register': "Acceda per unirsi al gruppo a cui è stato invitato.",
@@ -872,6 +884,8 @@ export default {
// Group misc
'group.retry': 'Riprova',
+ 'lazy.load_failed': 'Impossibile caricare questa parte dell\'interfaccia. L\'hub potrebbe essere stato aggiornato da quando questa pagina è stata aperta.',
+ 'lazy.reload': 'Ricarica la pagina',
// Sidebar
'sidebar.node': 'Node',
@@ -894,7 +908,7 @@ export default {
'node.service_restarting': 'Riavvio…',
'node.service_mode_hint': 'In esecuzione come servizio in background — si avvia all\'avvio del sistema, prima dell\'accesso.',
'node.startup_mode_label': 'Avvia automaticamente:',
- 'node.startup_mode_off': 'Disattivato (avvio manuale)',
+ 'node.startup_mode_off': 'Solo mentre MeshBay è aperto',
'node.startup_mode_signin': 'All\'accesso',
'node.startup_mode_service': 'Come servizio in background (si avvia all\'avvio del sistema)',
'node.startup_mode_updating': 'Cambio modalità — controlli se compare una richiesta di amministratore…',
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 622fa5a..b37a027 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -93,26 +93,36 @@ export default {
'login.locked': "このアカウントでパスフレーズの誤りが多すぎます。{minutes} 分後にもう一度お試しいただくか、パスフレーズをリセットしてください。",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': '本来あるべき姿のインターネット。',
- 'welcome.lead': 'MeshBay は、個人のファイルにどこからでもアクセスでき、自分のパソコンのストレージ上でアプリを動かせるオープンソースソフトウェアです。',
+ 'welcome.lead': '写真、音楽、動画、会話は、ご自宅の自分のパソコンに置いたまま。MeshBay なら、どこからでも楽しめて、選んだ相手と共有できます。',
'welcome.app_chat': 'チャット',
'welcome.app_photos': '写真',
'welcome.app_media': 'メディアセンター',
'welcome.app_video': '動画プレーヤー',
'welcome.app_music': '音楽プレーヤー',
- 'welcome.groups': 'グループを作成して、家族や友人に一部のアプリへのアクセスを許可できます。',
- 'welcome.e2e': 'データはエンドツーエンドで暗号化され、各デバイスからデータを保管するパソコン(あなたのノード)へピアツーピアで直接届きます。meshbay.org のハブを経由することは一切ありません。',
+ 'welcome.how_title': 'しくみ',
+ 'welcome.step_home': '自宅のパソコンに MeshBay をインストール。ファイルはその場所に置いたままです。',
+ 'welcome.step_anywhere': 'スマートフォン、ノートパソコン、どのブラウザからでも、どこにいてもログインできます。',
+ 'welcome.step_share': 'グループを作って、家族や友人を招待しましょう。',
'welcome.uses_title': 'できること',
'welcome.use_chat': 'グループ内でのインスタントメッセージ',
'welcome.use_photos': '家族や友人とのフォトアルバム共有',
'welcome.use_media': '自宅のメディアセンターに外出先からアクセス。ご家族みんなで使え、ストリーミングに対応し、デスクトップアプリからは Chromecast も利用できます',
'welcome.use_apps': 'ホストしているノード上で独自のアプリを開発',
- 'welcome.hub_title': 'meshbay.org の役割',
- 'welcome.hub_body': 'meshbay.org はシグナリング専用のサービスです。ログインを処理し、家庭のネットワーク越しに参加者同士を設定不要でつなぎます。',
- 'welcome.hub_never': 'あなたのコンテンツが届くことはありません:',
- 'welcome.never_transit': '何も経由しません(コンテンツもインデックスもなし)',
- 'welcome.never_stored': 'ファイルやメッセージは一切保存されません',
- 'welcome.never_e2e': '各デバイスからノードまでエンドツーエンドで暗号化',
- 'welcome.hub_free': 'サービスは無料です。ビジネスモデルは意図的に持たず、トラッキングも広告もありません。どうぞお楽しみください!',
+ 'welcome.private_title': '最初からプライベート',
+ 'welcome.private_body': 'ファイルやメッセージは暗号化され、あなたの端末から自分のパソコンへ直接届きます。meshbay.org は端末どうしをつなぐ手助けをするだけで、あなたのコンテンツが経由することはありません。',
+ 'welcome.badge_free': '無料',
+ 'welcome.badge_open': 'オープンソース',
+ 'welcome.badge_no_ads': '広告なし',
+ 'welcome.badge_no_tracking': 'トラッキングなし',
+ 'welcome.docs_title': 'ドキュメント',
+ 'welcome.docs_source': 'ソースコード',
+ 'welcome.docs_repo': 'git.meshbay.org',
+ 'welcome.docs_user': 'ユーザー向け',
+ 'welcome.docs_quickstart': 'クイックスタート',
+ 'welcome.docs_userguide': 'ユーザーガイド',
+ 'welcome.docs_devel': '開発者向け',
+ 'welcome.docs_design': '設計',
+ 'welcome.docs_protocol': 'プロトコル',
'welcome.download': 'ダウンロード(ベータ版)',
'welcome.legal': '法的情報',
'register.err_mismatch': 'パスワードが一致しません',
@@ -678,10 +688,10 @@ export default {
'members.invite_email_failed': 'メールを送信できませんでした。コードを手動で共有してください。',
'members.invite_email_opt': '招待をメールで送信(迷惑メールに入る場合があります)',
'members.link_title': "リンクで招待",
- 'members.link_hint': "まだアカウントを持っていない人向けです。リンクは1回だけ、このアドレスで登録したアカウントでのみ使えます。",
- 'members.link_email_placeholder': "メールアドレス",
+ 'members.link_hint': "まだアカウントを持っていないかもしれない人向けです。メッセージアプリやSMSなど、お好きな方法で送ってください。リンクは7日間有効で、最初に開いた人が一度だけ使えます。",
+ 'members.link_email_placeholder': "メールアドレス(任意)",
'members.link_btn': "リンクを作成",
- 'members.link_ready': "{email} への招待リンク:",
+ 'members.link_ready': "招待リンク — 7日間有効、1回限り:",
'members.link_copy': "コピー",
'members.link_copied': "コピーしました",
'members.link_email_sent': "リンクをメールで送信しました。",
@@ -690,9 +700,12 @@ export default {
'members.link_status_expired': "期限切れ",
'members.link_expires': "{date} に期限切れ",
'members.link_cancel': "取り消す",
+ 'members.link_share': "共有",
+ 'members.link_share_title': "招待",
+ 'members.link_unlabelled': "{date} に作成したリンク",
'invite.title': "招待されています",
'invite.none': "このタブで待機中の招待はありません。受け取ったリンクをもう一度開いてください。",
- 'invite.signed_out': "このハブのグループに招待されています。招待が送られたメールアドレスでアカウントを作成するか、すでにお持ちならサインインしてください。",
+ 'invite.signed_out': "このハブのグループに招待されています。アカウントを作成するか、すでにお持ちならサインインしてください。",
'invite.register': "アカウントを作成",
'invite.signin': "サインイン",
'invite.confirm': "{inviter} さんが {group} への参加に招待しています。",
@@ -700,7 +713,6 @@ export default {
'invite.ignore': "無視",
'invite.open': "グループを開く",
'invite.already_member': "すでに {group} のメンバーです。",
- 'invite.other_account': "この招待は別のメールアドレスに送られました。そのアドレスで登録したアカウントでサインインしてください。エイリアスやドットも完全に一致する必要があります。",
'invite.invalid': "この招待は無効です。使用済み、取り消し済み、または期限切れです。新しい招待を依頼してください。",
'invite.joining': "参加しています…",
'invite.after_register': "招待されたグループに参加するにはサインインしてください。",
@@ -860,6 +872,8 @@ export default {
// Group
'group.retry': '再試行',
+ 'lazy.load_failed': 'インターフェースのこの部分を読み込めませんでした。このページを開いた後に hub が更新された可能性があります。',
+ 'lazy.reload': 'ページを再読み込み',
// Sidebar
'sidebar.node': 'Node',
@@ -882,7 +896,7 @@ export default {
'node.service_restarting': '再起動中…',
'node.service_mode_hint': 'バックグラウンドサービスとして実行中 — サインインより前、起動時に開始します。',
'node.startup_mode_label': '自動的に開始:',
- 'node.startup_mode_off': 'オフ(手動で開始)',
+ 'node.startup_mode_off': 'MeshBay が開いている間のみ',
'node.startup_mode_signin': 'サインイン時',
'node.startup_mode_service': 'バックグラウンドサービスとして(起動時に開始)',
'node.startup_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…',
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 be0b43b..eeebaf8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -93,26 +93,36 @@ export default {
'login.locked': "Te veel onjuiste wachtwoordzinnen voor dit account. Probeer het over {minutes} min opnieuw of stel uw wachtwoordzin opnieuw in.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Het internet zoals het bedoeld was.',
- 'welcome.lead': 'MeshBay is opensourcesoftware waarmee u op afstand bij uw persoonlijke bestanden kunt, en die toepassingen draait bovenop de opslag van uw eigen computer:',
+ 'welcome.lead': 'Uw foto’s, muziek, video’s en gesprekken blijven thuis, op uw eigen computer. Met MeshBay geniet u er overal van en deelt u ze met wie u maar wilt.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Foto’s',
'welcome.app_media': 'Mediacenter',
'welcome.app_video': 'Videospeler',
'welcome.app_music': 'Muziekspeler',
- 'welcome.groups': 'Maak groepen aan om uw naasten toegang te geven tot een aantal van deze toepassingen.',
- 'welcome.e2e': 'Uw gegevens zijn end-to-end versleuteld en gaan peer-to-peer, rechtstreeks van elk apparaat naar de computer waarop ze staan: uw node. Ze gaan nooit via de hub meshbay.org.',
+ 'welcome.how_title': 'Zo werkt het',
+ 'welcome.step_home': 'Installeer MeshBay op een computer thuis. Uw bestanden blijven waar ze zijn.',
+ 'welcome.step_anywhere': 'Meld u aan vanaf uw telefoon, laptop of elke browser, waar u ook bent.',
+ 'welcome.step_share': 'Maak groepen en nodig familie en vrienden uit om mee te doen.',
'welcome.uses_title': 'Wat u ermee kunt doen',
'welcome.use_chat': 'Chatten binnen een groep',
'welcome.use_photos': 'Fotoalbums delen met familie en vrienden',
'welcome.use_media': 'Overal bij uw mediacenter thuis, voor u en uw huishouden, met streaming en Chromecast via de desktop-app',
'welcome.use_apps': 'Uw eigen toepassingen bouwen op de node die u host',
- 'welcome.hub_title': 'Wat meshbay.org doet',
- 'welcome.hub_body': 'meshbay.org is alleen een signaleringsdienst: het meldt u aan en brengt de partijen met elkaar in contact, plug-and-play, over thuisnetwerken heen.',
- 'welcome.hub_never': 'Uw inhoud komt er nooit terecht:',
- 'welcome.never_transit': 'Er gaat niets doorheen: geen inhoud, geen indexering',
- 'welcome.never_stored': 'Geen van uw bestanden of berichten wordt er opgeslagen',
- 'welcome.never_e2e': 'End-to-end versleuteling, van elk apparaat tot uw node',
- 'welcome.hub_free': 'De dienst is gratis. Er is bewust geen verdienmodel, geen tracking en geen advertenties. Veel plezier!',
+ 'welcome.private_title': 'Privé van nature',
+ 'welcome.private_body': 'Uw bestanden en berichten reizen versleuteld, rechtstreeks van uw apparaten naar uw eigen computer. meshbay.org helpt uw apparaten alleen elkaar te vinden: uw inhoud komt er nooit langs.',
+ 'welcome.badge_free': 'Gratis',
+ 'welcome.badge_open': 'Open source',
+ 'welcome.badge_no_ads': 'Geen advertenties',
+ 'welcome.badge_no_tracking': 'Geen tracking',
+ 'welcome.docs_title': 'Documentatie',
+ 'welcome.docs_source': 'Broncode',
+ 'welcome.docs_repo': 'git.meshbay.org',
+ 'welcome.docs_user': 'Gebruikersdocs',
+ 'welcome.docs_quickstart': 'Snelstart',
+ 'welcome.docs_userguide': 'Gebruikershandleiding',
+ 'welcome.docs_devel': 'Ontwikkelaarsdocs',
+ 'welcome.docs_design': 'Ontwerp',
+ 'welcome.docs_protocol': 'Protocol',
'welcome.download': 'Downloaden (bèta)',
'welcome.legal': 'Juridische informatie',
'register.err_mismatch': 'De wachtwoorden komen niet overeen',
@@ -687,10 +697,10 @@ export default {
'members.invite_email_failed': 'Kon de e-mail niet verzenden — deel de code handmatig.',
'members.invite_email_opt': 'Uitnodiging per e-mail versturen (kan in spam belanden)',
'members.link_title': "Uitnodigen via link",
- 'members.link_hint': "Voor iemand die misschien nog geen account heeft. De link werkt één keer, en alleen voor een account met dit adres.",
- 'members.link_email_placeholder': "E-mailadres",
+ 'members.link_hint': "Voor iemand die misschien nog geen account heeft. Verstuur hem zoals u wilt — berichtenapp, sms. De link werkt één keer, zeven dagen lang, voor wie hem als eerste opent.",
+ 'members.link_email_placeholder': "E-mailadres (optioneel)",
'members.link_btn': "Link maken",
- 'members.link_ready': "Uitnodigingslink voor {email}:",
+ 'members.link_ready': "Uitnodigingslink — 7 dagen geldig, eenmalig:",
'members.link_copy': "Kopiëren",
'members.link_copied': "Gekopieerd",
'members.link_email_sent': "De link is per e-mail verstuurd.",
@@ -699,9 +709,12 @@ export default {
'members.link_status_expired': "verlopen",
'members.link_expires': "verloopt op {date}",
'members.link_cancel': "Annuleren",
+ 'members.link_share': "Delen",
+ 'members.link_share_title': "Uitnodiging",
+ 'members.link_unlabelled': "link gemaakt op {date}",
'invite.title': "U bent uitgenodigd",
'invite.none': "Er wacht geen uitnodiging in dit tabblad. Open de ontvangen link opnieuw.",
- 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan met het e-mailadres waarnaar de uitnodiging is gestuurd, of meld u aan als u er al een hebt.",
+ 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan, of meld u aan als u er al een hebt.",
'invite.register': "Account maken",
'invite.signin': "Aanmelden",
'invite.confirm': "{inviter} nodigt u uit voor {group}.",
@@ -709,7 +722,6 @@ export default {
'invite.ignore': "Negeren",
'invite.open': "Groep openen",
'invite.already_member': "U bent al lid van {group}.",
- 'invite.other_account': "Deze uitnodiging is naar een ander e-mailadres gestuurd. Meld u aan met het account van dat adres — aliassen en punten moeten exact overeenkomen.",
'invite.invalid': "Deze uitnodiging is niet meer geldig: ze is gebruikt, geannuleerd of verlopen. Vraag een nieuwe.",
'invite.joining': "Deelnemen…",
'invite.after_register': "Meld u aan om deel te nemen aan de groep waarvoor u bent uitgenodigd.",
@@ -874,6 +886,8 @@ export default {
// Group
'group.retry': 'Opnieuw proberen',
+ 'lazy.load_failed': 'Dit deel van de interface kon niet worden geladen. De hub is mogelijk bijgewerkt sinds deze pagina werd geopend.',
+ 'lazy.reload': 'Pagina opnieuw laden',
// Sidebar
'sidebar.node': 'Node',
@@ -896,7 +910,7 @@ export default {
'node.service_restarting': 'Herstarten…',
'node.service_mode_hint': 'Actief als achtergrondservice — start bij het opstarten, vóór het aanmelden.',
'node.startup_mode_label': 'Automatisch starten:',
- 'node.startup_mode_off': 'Uit (handmatig starten)',
+ 'node.startup_mode_off': 'Alleen zolang MeshBay open is',
'node.startup_mode_signin': 'Bij aanmelden',
'node.startup_mode_service': 'Als achtergrondservice (start bij het opstarten)',
'node.startup_mode_updating': 'Modus wijzigen — let op een beheerdersprompt…',
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 6e88a65..9be95db 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -96,26 +96,36 @@ export default {
'login.locked': "Zbyt wiele błędnych haseł-fraz dla tego konta. Spróbuj ponownie za {minutes} min lub zresetuj hasło-frazę.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Internet taki, jaki miał być.',
- 'welcome.lead': 'MeshBay to oprogramowanie open source, które daje zdalny dostęp do Twoich plików osobistych i uruchamia aplikacje korzystające z pamięci Twojego własnego komputera:',
+ 'welcome.lead': 'Twoje zdjęcia, muzyka, filmy i rozmowy zostają w domu, na Twoim własnym komputerze. MeshBay pozwala korzystać z nich z dowolnego miejsca i dzielić się nimi z wybranymi osobami.',
'welcome.app_chat': 'Czat',
'welcome.app_photos': 'Zdjęcia',
'welcome.app_media': 'Centrum multimedialne',
'welcome.app_video': 'Odtwarzacz wideo',
'welcome.app_music': 'Odtwarzacz muzyki',
- 'welcome.groups': 'Twórz grupy, aby dać bliskim dostęp do wybranych aplikacji.',
- 'welcome.e2e': 'Twoje dane są szyfrowane end-to-end i przesyłane bezpośrednio (peer-to-peer) z każdego urządzenia do komputera, który je przechowuje (Twojego węzła). Nigdy nie przechodzą przez hub meshbay.org.',
+ 'welcome.how_title': 'Jak to działa',
+ 'welcome.step_home': 'Zainstaluj MeshBay na komputerze w domu. Twoje pliki zostają tam, gdzie są.',
+ 'welcome.step_anywhere': 'Zaloguj się z telefonu, laptopa lub dowolnej przeglądarki, gdziekolwiek jesteś.',
+ 'welcome.step_share': 'Twórz grupy i zapraszaj do nich rodzinę i przyjaciół.',
'welcome.uses_title': 'Co możesz z nim robić',
'welcome.use_chat': 'Komunikator w obrębie grupy',
'welcome.use_photos': 'Udostępnianie albumów ze zdjęciami rodzinie i znajomym',
'welcome.use_media': 'Zdalny dostęp do domowego centrum multimedialnego dla Ciebie i domowników (ze streamingiem i Chromecastem w aplikacji desktopowej)',
'welcome.use_apps': 'Tworzenie własnych aplikacji na węźle, który hostujesz',
- 'welcome.hub_title': 'Czym zajmuje się meshbay.org',
- 'welcome.hub_body': 'meshbay.org to wyłącznie usługa sygnalizacyjna: uwierzytelnia Cię i łączy uczestników ze sobą, bez konfiguracji, także przez sieci domowe.',
- 'welcome.hub_never': 'Twoje treści nigdy do niego nie trafiają:',
- 'welcome.never_transit': 'Nic przez niego nie przechodzi: żadnych treści, żadnego indeksowania',
- 'welcome.never_stored': 'Żadne Twoje pliki ani wiadomości nie są na nim przechowywane',
- 'welcome.never_e2e': 'Szyfrowanie end-to-end, od każdego urządzenia do Twojego węzła',
- 'welcome.hub_free': 'Usługa jest bezpłatna. Celowo nie ma modelu biznesowego, śledzenia ani reklam. Miłego korzystania!',
+ 'welcome.private_title': 'Prywatność w standardzie',
+ 'welcome.private_body': 'Twoje pliki i wiadomości podróżują zaszyfrowane, prosto z Twoich urządzeń do Twojego komputera. meshbay.org jedynie pomaga urządzeniom się odnaleźć: Twoje treści nigdy przez niego nie przechodzą.',
+ 'welcome.badge_free': 'Za darmo',
+ 'welcome.badge_open': 'Open source',
+ 'welcome.badge_no_ads': 'Bez reklam',
+ 'welcome.badge_no_tracking': 'Bez śledzenia',
+ 'welcome.docs_title': 'Dokumentacja',
+ 'welcome.docs_source': 'Kod źródłowy',
+ 'welcome.docs_repo': 'git.meshbay.org',
+ 'welcome.docs_user': 'Dla użytkowników',
+ 'welcome.docs_quickstart': 'Szybki start',
+ 'welcome.docs_userguide': 'Przewodnik',
+ 'welcome.docs_devel': 'Dla programistów',
+ 'welcome.docs_design': 'Architektura',
+ 'welcome.docs_protocol': 'Protokół',
'welcome.download': 'Pobierz (beta)',
'welcome.legal': 'Informacje prawne',
'register.err_mismatch': 'Hasła nie są zgodne',
@@ -699,10 +709,10 @@ export default {
'members.invite_email_failed': 'Nie udało się wysłać e-maila — przekaż kod ręcznie.',
'members.invite_email_opt': 'Wyślij zaproszenie e-mailem (może trafić do spamu)',
'members.link_title': "Zaproś linkiem",
- 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Link działa raz i tylko dla konta założonego na ten adres.",
- 'members.link_email_placeholder': "Adres e-mail",
+ 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Wyślij go, jak chcesz — komunikatorem, SMS-em. Link działa raz, przez siedem dni, dla osoby, która otworzy go pierwsza.",
+ 'members.link_email_placeholder': "Adres e-mail (opcjonalnie)",
'members.link_btn': "Utwórz link",
- 'members.link_ready': "Link zaproszenia dla {email}:",
+ 'members.link_ready': "Link z zaproszeniem — ważny 7 dni, jednorazowy:",
'members.link_copy': "Kopiuj",
'members.link_copied': "Skopiowano",
'members.link_email_sent': "Link został wysłany e-mailem.",
@@ -711,9 +721,12 @@ export default {
'members.link_status_expired': "wygasł",
'members.link_expires': "wygasa {date}",
'members.link_cancel': "Anuluj",
+ 'members.link_share': "Udostępnij",
+ 'members.link_share_title': "Zaproszenie",
+ 'members.link_unlabelled': "link utworzony {date}",
'invite.title': "Otrzymano zaproszenie",
'invite.none': "W tej karcie nie czeka żadne zaproszenie. Otwórz ponownie otrzymany link.",
- 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto na adres e-mail, na który wysłano zaproszenie, albo zaloguj się, jeśli już je masz.",
+ 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto albo zaloguj się, jeśli już je masz.",
'invite.register': "Załóż konto",
'invite.signin': "Zaloguj się",
'invite.confirm': "{inviter} zaprasza cię do {group}.",
@@ -721,7 +734,6 @@ export default {
'invite.ignore': "Ignoruj",
'invite.open': "Otwórz grupę",
'invite.already_member': "Jesteś już członkiem {group}.",
- 'invite.other_account': "To zaproszenie wysłano na inny adres e-mail. Zaloguj się na konto założone na ten adres — aliasy i kropki muszą się dokładnie zgadzać.",
'invite.invalid': "To zaproszenie jest już nieważne: zostało użyte, anulowane lub wygasło. Poproś o nowe.",
'invite.joining': "Dołączanie…",
'invite.after_register': "Zaloguj się, aby dołączyć do grupy, do której cię zaproszono.",
@@ -892,6 +904,8 @@ export default {
// Group (supplementary)
'group.retry': 'Ponów',
+ 'lazy.load_failed': 'Nie udało się wczytać tej części interfejsu. Hub mógł zostać zaktualizowany od czasu otwarcia tej strony.',
+ 'lazy.reload': 'Wczytaj stronę ponownie',
// Sidebar (supplementary)
'sidebar.node': 'Node',
@@ -914,7 +928,7 @@ export default {
'node.service_restarting': 'Ponowne uruchamianie…',
'node.service_mode_hint': 'Działa jako usługa w tle — uruchamia się przy starcie systemu, przed zalogowaniem.',
'node.startup_mode_label': 'Uruchamiaj automatycznie:',
- 'node.startup_mode_off': 'Wyłączone (uruchamianie ręczne)',
+ 'node.startup_mode_off': 'Tylko gdy MeshBay jest otwarty',
'node.startup_mode_signin': 'Przy logowaniu',
'node.startup_mode_service': 'Jako usługa w tle (uruchamia się przy starcie systemu)',
'node.startup_mode_updating': 'Zmiana trybu — proszę sprawdzić, czy pojawiło się okno uprawnień administratora…',
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 edb9146..cea304e 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
@@ -94,26 +94,36 @@ export default {
'login.locked': "Muitas frases secretas incorretas para esta conta. Tente novamente em {minutes} min ou redefina sua frase secreta.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'A internet como deveria ser.',
- 'welcome.lead': 'O MeshBay é um software de código aberto que dá acesso remoto aos seus arquivos pessoais e executa aplicativos sobre o armazenamento do seu próprio computador:',
+ 'welcome.lead': 'Suas fotos, músicas, vídeos e conversas ficam em casa, no seu próprio computador. O MeshBay permite que você aproveite tudo de qualquer lugar e compartilhe com as pessoas que escolher.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Fotos',
'welcome.app_media': 'Central de mídia',
'welcome.app_video': 'Player de vídeo',
'welcome.app_music': 'Player de música',
- 'welcome.groups': 'Crie grupos para dar às pessoas próximas acesso a alguns desses aplicativos.',
- 'welcome.e2e': 'Seus dados são criptografados de ponta a ponta e trafegam ponto a ponto, direto de cada dispositivo para o computador que os hospeda: o seu nó. Eles nunca passam pelo hub meshbay.org.',
+ 'welcome.how_title': 'Como funciona',
+ 'welcome.step_home': 'Instale o MeshBay em um computador de casa. Seus arquivos ficam onde estão.',
+ 'welcome.step_anywhere': 'Entre pelo celular, pelo notebook ou por qualquer navegador, onde você estiver.',
+ 'welcome.step_share': 'Crie grupos e convide sua família e seus amigos para participar.',
'welcome.uses_title': 'O que você pode fazer com ele',
'welcome.use_chat': 'Mensagens instantâneas dentro de um grupo',
'welcome.use_photos': 'Compartilhar álbuns de fotos com a família e os amigos',
'welcome.use_media': 'Acessar de qualquer lugar a sua central de mídia, para você e sua casa, com streaming e Chromecast pelo app para desktop',
'welcome.use_apps': 'Criar seus próprios aplicativos no nó que você hospeda',
- 'welcome.hub_title': 'O que o meshbay.org faz',
- 'welcome.hub_body': 'O meshbay.org é apenas um serviço de sinalização: ele autentica você e conecta as partes entre si, plug-and-play, através de redes domésticas.',
- 'welcome.hub_never': 'Seu conteúdo nunca chega até ele:',
- 'welcome.never_transit': 'Nada passa por ele: nenhum conteúdo, nenhuma indexação',
- 'welcome.never_stored': 'Nenhum dos seus arquivos ou mensagens fica armazenado nele',
- 'welcome.never_e2e': 'Criptografia de ponta a ponta, de cada dispositivo até o seu nó',
- 'welcome.hub_free': 'O serviço é gratuito. Não há modelo de negócio (de propósito), rastreamento nem anúncios. Aproveite!',
+ 'welcome.private_title': 'Privado por natureza',
+ 'welcome.private_body': 'Seus arquivos e mensagens viajam criptografados, direto dos seus dispositivos para o seu próprio computador. O meshbay.org só ajuda seus dispositivos a se encontrarem: seu conteúdo nunca passa por ele.',
+ 'welcome.badge_free': 'Gratuito',
+ 'welcome.badge_open': 'Código aberto',
+ 'welcome.badge_no_ads': 'Sem anúncios',
+ 'welcome.badge_no_tracking': 'Sem rastreamento',
+ 'welcome.docs_title': 'Documentação',
+ 'welcome.docs_source': 'Código-fonte',
+ 'welcome.docs_repo': 'git.meshbay.org',
+ 'welcome.docs_user': 'Docs do usuário',
+ 'welcome.docs_quickstart': 'Início rápido',
+ 'welcome.docs_userguide': 'Guia do usuário',
+ 'welcome.docs_devel': 'Docs de desenvolvimento',
+ 'welcome.docs_design': 'Arquitetura',
+ 'welcome.docs_protocol': 'Protocolo',
'welcome.download': 'Baixar (beta)',
'welcome.legal': 'Informações legais',
'register.err_mismatch': 'As senhas não coincidem',
@@ -685,10 +695,10 @@ export default {
'members.invite_email_failed': 'Não foi possível enviar o e-mail — compartilhe o código manualmente.',
'members.invite_email_opt': 'Enviar o convite por e-mail (pode cair no spam)',
'members.link_title': "Convidar por link",
- 'members.link_hint': "Para alguém que talvez ainda não tenha conta. O link funciona uma vez, e só para uma conta registrada com este endereço.",
- 'members.link_email_placeholder': "Endereço de e-mail",
+ 'members.link_hint': "Para alguém que talvez ainda não tenha conta. Envie como preferir — aplicativo de mensagens, SMS. O link funciona uma vez, por sete dias, para quem abrir primeiro.",
+ 'members.link_email_placeholder': "Endereço de e-mail (opcional)",
'members.link_btn': "Criar link",
- 'members.link_ready': "Link de convite para {email}:",
+ 'members.link_ready': "Link de convite — válido por 7 dias, uso único:",
'members.link_copy': "Copiar",
'members.link_copied': "Copiado",
'members.link_email_sent': "O link foi enviado por e-mail.",
@@ -697,9 +707,12 @@ export default {
'members.link_status_expired': "expirado",
'members.link_expires': "expira em {date}",
'members.link_cancel': "Cancelar",
+ 'members.link_share': "Compartilhar",
+ 'members.link_share_title': "Convite",
+ 'members.link_unlabelled': "link criado em {date}",
'invite.title': "Você foi convidado",
'invite.none': "Não há convite aguardando nesta aba. Abra novamente o link que recebeu.",
- 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta com o endereço de e-mail para o qual o convite foi enviado, ou entre se já tiver uma.",
+ 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta, ou entre se já tiver uma.",
'invite.register': "Criar uma conta",
'invite.signin': "Entrar",
'invite.confirm': "{inviter} convida você para participar de {group}.",
@@ -707,7 +720,6 @@ export default {
'invite.ignore': "Ignorar",
'invite.open': "Abrir o grupo",
'invite.already_member': "Você já é membro de {group}.",
- 'invite.other_account': "Este convite foi enviado para outro endereço de e-mail. Entre com a conta registrada com esse endereço — aliases e pontos devem coincidir exatamente.",
'invite.invalid': "Este convite não é mais válido: foi usado, cancelado ou expirou. Peça um novo.",
'invite.joining': "Entrando…",
'invite.after_register': "Entre para participar do grupo para o qual foi convidado.",
@@ -871,6 +883,8 @@ export default {
// Group misc
'group.retry': 'Tentar novamente',
+ 'lazy.load_failed': 'Não foi possível carregar esta parte da interface. O hub pode ter sido atualizado desde que esta página foi aberta.',
+ 'lazy.reload': 'Recarregar a página',
// Sidebar
'sidebar.node': 'Node',
@@ -893,7 +907,7 @@ export default {
'node.service_restarting': 'Reiniciando…',
'node.service_mode_hint': 'Em execução como serviço em segundo plano — inicia na inicialização, antes do login.',
'node.startup_mode_label': 'Iniciar automaticamente:',
- 'node.startup_mode_off': 'Desativado (iniciar manualmente)',
+ 'node.startup_mode_off': 'Somente enquanto o MeshBay estiver aberto',
'node.startup_mode_signin': 'Ao entrar na sessão',
'node.startup_mode_service': 'Como serviço em segundo plano (inicia na inicialização)',
'node.startup_mode_updating': 'Alternando modo — verifique se aparece um aviso de administrador…',
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 4f5034d..92952f4 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
@@ -93,26 +93,36 @@ export default {
'login.locked': "此账户输错密码短语的次数过多。请在 {minutes} 分钟后重试,或重置密码短语。",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': '互联网本该有的样子。',
- 'welcome.lead': 'MeshBay 是一款开源软件,让您远程访问个人文件,并在您自己电脑的存储之上运行应用:',
+ 'welcome.lead': '您的照片、音乐、视频和聊天都留在家里,保存在您自己的电脑上。MeshBay 让您随时随地享用它们,并与您选择的人分享。',
'welcome.app_chat': '聊天',
'welcome.app_photos': '照片',
'welcome.app_media': '媒体中心',
'welcome.app_video': '视频播放器',
'welcome.app_music': '音乐播放器',
- 'welcome.groups': '创建群组,让亲友使用其中部分应用。',
- 'welcome.e2e': '您的数据经过端到端加密,以点对点方式从每台设备直接传输到存放数据的电脑(也就是您的节点)。数据从不经过 meshbay.org 中心服务器。',
+ 'welcome.how_title': '使用方式',
+ 'welcome.step_home': '在家里的电脑上安装 MeshBay。您的文件原地不动。',
+ 'welcome.step_anywhere': '无论身在何处,都可以用手机、笔记本或任意浏览器登录。',
+ 'welcome.step_share': '创建群组,邀请家人和朋友加入。',
'welcome.uses_title': '您可以用它做什么',
'welcome.use_chat': '群组内即时通讯',
'welcome.use_photos': '与家人朋友分享相册',
'welcome.use_media': '随时随地访问家中的媒体中心,供您和家人使用(支持流媒体播放,桌面应用还支持 Chromecast)',
'welcome.use_apps': '在您托管的节点上开发自己的应用',
- 'welcome.hub_title': 'meshbay.org 的作用',
- 'welcome.hub_body': 'meshbay.org 仅是一个信令服务:它负责登录验证,并以即插即用的方式跨家庭网络连接各方。',
- 'welcome.hub_never': '您的内容永远不会到达这里:',
- 'welcome.never_transit': '没有任何数据经过它:没有内容,也没有索引',
- 'welcome.never_stored': '不存储您的任何文件或消息',
- 'welcome.never_e2e': '从每台设备到您的节点全程端到端加密',
- 'welcome.hub_free': '本服务免费。我们刻意不设商业模式,没有跟踪,也没有广告。尽情享用吧!',
+ 'welcome.private_title': '生来私密',
+ 'welcome.private_body': '您的文件和消息经过加密,从您的设备直接传到您自己的电脑。meshbay.org 只负责帮您的设备找到彼此:您的内容从不经过它。',
+ 'welcome.badge_free': '免费',
+ 'welcome.badge_open': '开源',
+ 'welcome.badge_no_ads': '无广告',
+ 'welcome.badge_no_tracking': '无追踪',
+ 'welcome.docs_title': '文档',
+ 'welcome.docs_source': '源代码',
+ 'welcome.docs_repo': 'git.meshbay.org',
+ 'welcome.docs_user': '用户文档',
+ 'welcome.docs_quickstart': '快速入门',
+ 'welcome.docs_userguide': '用户指南',
+ 'welcome.docs_devel': '开发文档',
+ 'welcome.docs_design': '设计',
+ 'welcome.docs_protocol': '协议',
'welcome.download': '下载(测试版)',
'welcome.legal': '法律信息',
'register.err_mismatch': '两次输入的密码不一致',
@@ -667,10 +677,10 @@ export default {
'members.invite_email_failed': '无法发送邮件——请手动分享验证码。',
'members.invite_email_opt': '通过电子邮件发送邀请(可能进入垃圾邮件)',
'members.link_title': "通过链接邀请",
- 'members.link_hint': "适用于可能还没有账户的人。该链接只能使用一次,且仅限使用此地址注册的账户。",
- 'members.link_email_placeholder': "电子邮件地址",
+ 'members.link_hint': "适用于可能还没有账户的人。可以用任何方式发送——即时通讯应用、短信。链接仅可使用一次,有效期七天,归最先打开的人。",
+ 'members.link_email_placeholder': "电子邮件地址(可选)",
'members.link_btn': "创建链接",
- 'members.link_ready': "发给 {email} 的邀请链接:",
+ 'members.link_ready': "邀请链接——有效期 7 天,仅限一次:",
'members.link_copy': "复制",
'members.link_copied': "已复制",
'members.link_email_sent': "链接已通过电子邮件发送。",
@@ -679,9 +689,12 @@ export default {
'members.link_status_expired': "已过期",
'members.link_expires': "{date} 过期",
'members.link_cancel': "取消",
+ 'members.link_share': "分享",
+ 'members.link_share_title': "邀请",
+ 'members.link_unlabelled': "{date} 创建的链接",
'invite.title': "您收到了邀请",
'invite.none': "此标签页中没有待处理的邀请。请重新打开您收到的链接。",
- 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请使用收到邀请的电子邮件地址创建账户,如果已有账户请登录。",
+ 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请创建账户,如果已有账户请登录。",
'invite.register': "创建账户",
'invite.signin': "登录",
'invite.confirm': "{inviter} 邀请您加入 {group}。",
@@ -689,7 +702,6 @@ export default {
'invite.ignore': "忽略",
'invite.open': "打开群组",
'invite.already_member': "您已经是 {group} 的成员。",
- 'invite.other_account': "此邀请发送到了另一个电子邮件地址。请使用该地址注册的账户登录——别名和点号必须完全一致。",
'invite.invalid': "此邀请已失效:已被使用、取消或已过期。请索取新的邀请。",
'invite.joining': "正在加入…",
'invite.after_register': "登录以加入您受邀的群组。",
@@ -848,6 +860,8 @@ export default {
// Group
'group.retry': '重试',
+ 'lazy.load_failed': '无法加载界面的这一部分。自打开此页面以来,hub 可能已更新。',
+ 'lazy.reload': '重新加载页面',
// Sidebar
'sidebar.node': 'Node',
@@ -870,7 +884,7 @@ export default {
'node.service_restarting': '正在重启…',
'node.service_mode_hint': '以后台服务方式运行 — 在开机时启动,早于登录。',
'node.startup_mode_label': '自动启动:',
- 'node.startup_mode_off': '关闭(手动启动)',
+ 'node.startup_mode_off': '仅在 MeshBay 打开时',
'node.startup_mode_signin': '登录时',
'node.startup_mode_service': '作为后台服务(开机时启动)',
'node.startup_mode_updating': '正在切换模式 — 请留意管理员权限提示…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index a59c100..7fd7ab1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -13,6 +13,14 @@ import { HUB } from './hub-client.js';
// (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain
// for potential future browser-side use.
+// true / false from a node that has read its roster, null from one that has
+// not yet (it publishes the roster only once it has signed in to the hub) --
+// which is not the same as having no operator.
+export function pairedFrom(result) {
+ const v = result && result.operator_paired;
+ return v === true || v === false ? v : null;
+}
+
function NodeServicePanel({ onChanged, token, username }) {
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState('');
@@ -202,7 +210,7 @@ export function NodePage({ groups, token, username }) {
const [editIce, setEditIce] = useState(null);
const [savingIce, setSavingIce] = useState(false);
const [iceInput, setIceInput] = useState('');
- const [operatorPaired, setOperatorPaired] = useState(false);
+ const [operatorPaired, setOperatorPaired] = useState(null);
const [pairBusy, setPairBusy] = useState(false);
const [pairStatus, setPairStatus] = useState('');
const [nodeInfo, setNodeInfo] = useState(null);
@@ -231,7 +239,7 @@ export function NodePage({ groups, token, username }) {
}
const result = await nodeCall('GET', '/api/groups');
setNodeGroups(result.groups || []);
- setOperatorPaired(!!result.operator_paired);
+ setOperatorPaired(pairedFrom(result));
setNodeSettings(result.settings || null);
try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {}
setStatus('connected');
@@ -253,7 +261,7 @@ export function NodePage({ groups, token, username }) {
try {
const result = await nodeCall('GET', '/api/groups');
setNodeGroups(result.groups || []);
- setOperatorPaired(!!result.operator_paired);
+ setOperatorPaired(pairedFrom(result));
setNodeSettings(result.settings || null);
try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {}
} catch {}
@@ -711,7 +719,7 @@ export function NodePage({ groups, token, username }) {
</div>
<${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} />
${actionMsg && html`<div class="node-message">${actionMsg}</div>`}
- ${!operatorPaired && html`
+ ${operatorPaired === false && html`
<div class="node-pair-banner">
<p>${t('node.pair_needed')}</p>
<button class="btn btn-primary btn-small"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/og-image.jpg b/packages/meshbay-hub/src/meshbay_hub/static/og-image.jpg
new file mode 100644
index 0000000..99fd7e1
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/og-image.jpg
Binary files differ
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index ed7a2fa..d3d06d7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -37,6 +37,10 @@ export function ProfilePage({ user, onLogout }) {
const [emailVerifyPending, setEmailVerifyPending] = useState(false);
const [emailCode, setEmailCode] = useState('');
const [emailVerifying, setEmailVerifying] = useState(false);
+ // Changing the address on file now needs the passphrase (the hub verifies the
+ // derived auth_key): a bare access token — which every node this account
+ // connects to is handed — must not be able to start an account takeover.
+ const [emailPass, setEmailPass] = useState('');
const [pinCount, setPinCount] = useState(
() => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0));
const [delOpen, setDelOpen] = useState(false);
@@ -232,12 +236,18 @@ export function ProfilePage({ user, onLogout }) {
const saveEmail = useCallback(async () => {
const val = emailDraft.trim();
if (!val || val === email) { setEmailEditing(false); return; }
+ if (!emailPass) return; // the Save button is disabled until it is entered
setEmailSaving(true);
setEmailStatus('');
try {
+ // The passphrase is the second factor here, exactly as for a passphrase
+ // change or an account deletion: the hub gets the derived auth_key, never
+ // the passphrase itself.
+ const authKey = await window.MeshBayKeys.deriveAuthKey(emailPass, user.username);
const resp = await hubFetch('/v1/users/me', {
- method: 'PATCH', token: user.token, body: { email: val },
+ method: 'PATCH', token: user.token, body: { email: val, auth_key: authKey },
});
+ setEmailPass('');
if (resp.email_verification_required) {
setEmailVerifyPending(true);
setEmailStatus(t('settings.email_code_sent'));
@@ -248,11 +258,13 @@ export function ProfilePage({ user, onLogout }) {
setTimeout(() => setEmailStatus(''), 3000);
}
} catch (e) {
- setEmailStatus(e.message);
+ const msg = /403|does not match/i.test(e.message)
+ ? t('settings.pw_wrong_current') : e.message;
+ setEmailStatus(msg);
} finally {
setEmailSaving(false);
}
- }, [emailDraft, email, user.token]);
+ }, [emailDraft, email, emailPass, user.token, user.username]);
const verifyEmailChange = useCallback(async () => {
if (!emailCode.trim()) return;
@@ -311,14 +323,18 @@ export function ProfilePage({ user, onLogout }) {
? html`<span style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="email" value=${emailDraft}
onInput=${e => setEmailDraft(e.target.value)}
- onKeyDown=${e => e.key === 'Enter' && saveEmail()}
disabled=${emailVerifyPending}
style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" />
${!emailVerifyPending && html`
+ <input type="password" autocomplete="current-password"
+ placeholder=${t('login.password')}
+ value=${emailPass} onInput=${e => setEmailPass(e.target.value)}
+ onKeyDown=${e => e.key === 'Enter' && saveEmail()}
+ style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" />
<button class="admin-btn" onClick=${saveEmail}
- disabled=${emailSaving}>${t('settings.email_save')}</button>
+ disabled=${emailSaving || !emailPass}>${t('settings.email_save')}</button>
<button class="btn-secondary" onClick=${() => {
- setEmailEditing(false); setEmailDraft(email);
+ setEmailEditing(false); setEmailDraft(email); setEmailPass('');
}}>${t('settings.cancel')}</button>
`}
</span>`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/robots.txt b/packages/meshbay-hub/src/meshbay_hub/static/robots.txt
new file mode 100644
index 0000000..a2ef43d
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/robots.txt
@@ -0,0 +1,9 @@
+# Served by the hub at the root of its origin, meshbay.org's included (the
+# Caddyfile sends every path the public site does not name to the hub).
+#
+# The sign-in page at /app may be indexed; nothing behind it can be, since it
+# is a signed-in view, and /v1/ is the API. /a/ stays open on purpose: a
+# crawler that renders /app needs the scripts and the stylesheet.
+User-agent: *
+Disallow: /app/
+Disallow: /v1/
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 5f16a6c..29d4181 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -491,6 +491,14 @@ a:hover { text-decoration: underline; }
margin-bottom: 24px;
}
+/* lazy.js: a view whose code could not be fetched says so, in place of the
+ spinner it would otherwise have kept for good. Inside the player's overlay
+ the backdrop is near-black whatever the theme, so the text colour cannot
+ come from the theme there. */
+.lazy-failed { text-align: center; max-width: 520px; margin: 24px auto; }
+.lazy-failed p { margin-bottom: 12px; }
+.video-overlay .lazy-failed { color: #e2e8f0; }
+
/* ── Page center (login / register) ───────────────────────────────────────── */
.page-center {
@@ -670,7 +678,47 @@ a:hover { text-decoration: underline; }
keyboard) and `row-reverse` is what moves it right. Headings are
`.welcome-pitch .welcome-h` rather than a bare class so they outrank
`.main h2`. */
+/* The whole sign-in page sits on one dark backdrop, in either theme: steel
+ blue at the top left, night blue pooling at the bottom left, charcoal to near
+ black at the bottom right. Drawn in CSS alone, a fixed layer under the
+ page. The theme tokens are redefined for everything on it, so the form, the
+ cards and the chips keep their own rules and still read. */
+.welcome-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ overflow: hidden;
+ pointer-events: none;
+ background: linear-gradient(155deg,
+ #809cbc 0%, #6a819b 18%, #3d4d61 42%, #232b36 66%, #0f1113 100%);
+}
+/* The night-blue pool. */
+.welcome-backdrop::before {
+ content: '';
+ position: absolute;
+ left: -25%;
+ bottom: -30%;
+ width: 85%;
+ height: 90%;
+ background: radial-gradient(closest-side, rgba(26, 46, 110, 0.95), transparent);
+ filter: blur(36px);
+}
+
.welcome {
+ --text: #f4f6fa;
+ --text-secondary: rgba(236, 241, 248, 0.80);
+ --text-dim: rgba(226, 233, 243, 0.60);
+ --border: rgba(255, 255, 255, 0.14);
+ --bg-base: rgba(255, 255, 255, 0.06);
+ --bg-surface: rgba(255, 255, 255, 0.07);
+ --accent-bg: rgba(255, 255, 255, 0.10);
+ --accent: #8fd0ff;
+ --accent-hover: #b5e0ff;
+ --accent-text: #0b1220;
+ --border-focus: #8fd0ff;
+ position: relative;
+ z-index: 1;
+ color: var(--text);
display: flex;
flex-direction: row-reverse;
align-items: flex-start;
@@ -679,7 +727,16 @@ a:hover { text-decoration: underline; }
width: 100%;
max-width: 1180px;
}
-.welcome > .login-card { flex: 0 0 380px; }
+.welcome-side { display: flex; flex: 0 0 380px; flex-direction: column; gap: 16px; }
+/* Frosted glass over the backdrop rather than a white sheet on it. */
+.welcome .login-card {
+ border-color: rgba(255, 255, 255, 0.16);
+ background: rgba(12, 16, 22, 0.45);
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
+ -webkit-backdrop-filter: blur(14px);
+ backdrop-filter: blur(14px);
+}
+.welcome .login-card h2 { color: var(--text); }
.welcome-pitch {
flex: 1 1 0;
@@ -691,13 +748,14 @@ a:hover { text-decoration: underline; }
}
.welcome-pitch p { margin-bottom: 10px; }
-.welcome-apps, .welcome-uses, .welcome-never { list-style: none; }
+.welcome-apps, .welcome-steps, .welcome-uses, .welcome-badges,
+.welcome-docs ul { list-style: none; }
.welcome-title {
- margin-bottom: 10px;
+ margin-bottom: 12px;
color: var(--text);
- font-size: 1.9em;
- font-weight: 700;
+ font-size: 2.3em;
+ font-weight: 500;
line-height: 1.2;
letter-spacing: -0.02em;
text-wrap: balance;
@@ -718,18 +776,6 @@ a:hover { text-decoration: underline; }
}
.welcome-apps .icon { color: var(--accent); }
-.welcome-e2e {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- padding: 8px 14px;
- border-left: 3px solid var(--accent);
- border-radius: 0 6px 6px 0;
- background: var(--accent-bg);
- color: var(--text);
-}
-.welcome-e2e .icon { margin-top: 0.3em; color: var(--accent); }
-
.welcome-pitch .welcome-h {
margin: 18px 0 8px;
color: var(--text-dim);
@@ -748,7 +794,39 @@ a:hover { text-decoration: underline; }
font-size: 0.92em;
line-height: 1.45;
}
-.welcome-use-icon {
+/* How it works: three cards side by side, numbered by the counter so the
+ translation only carries the sentence. */
+.welcome-steps {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ counter-reset: welcome-step;
+}
+.welcome-steps li {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 14px 14px 12px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--bg-surface);
+ color: var(--text);
+ font-size: 0.9em;
+ line-height: 1.45;
+ counter-increment: welcome-step;
+}
+.welcome-steps li::after {
+ content: counter(welcome-step);
+ position: absolute;
+ top: 10px;
+ right: 12px;
+ color: var(--text-dim);
+ font-size: 1.3em;
+ font-weight: 700;
+ opacity: 0.5;
+}
+.welcome-step-icon, .welcome-use-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
@@ -761,57 +839,108 @@ a:hover { text-decoration: underline; }
font-size: 17px;
}
-.welcome-hub {
- margin-top: 18px;
+/* Privacy, said once: a warm card rather than a list of denials. */
+.welcome-private {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ margin-top: 20px;
+ padding: 16px 18px;
+ border-radius: 12px;
+ background: var(--accent-bg);
+ color: var(--text);
+}
+.welcome-private-icon {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--accent);
+ color: var(--accent-text);
+ font-size: 20px;
+}
+.welcome-pitch .welcome-private-title {
+ margin: 2px 0 4px;
+ color: var(--text);
+ font-size: 1.05em;
+ font-weight: 600;
+}
+.welcome-pitch .welcome-private p { margin-bottom: 10px; }
+.welcome-badges { display: flex; flex-wrap: wrap; gap: 6px; }
+.welcome-badges li {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 10px;
+ border-radius: 999px;
+ background: var(--bg-surface);
+ color: var(--text);
+ font-size: 0.82em;
+ font-weight: 500;
+}
+.welcome-badges .icon { color: var(--success); }
+
+.welcome-docs {
+ margin-top: 16px;
padding: 12px 18px;
border: 1px solid var(--border);
- border-radius: 8px;
+ border-radius: 10px;
background: var(--bg-surface);
- font-size: 0.92em;
+ font-size: 0.9em;
}
-.welcome-pitch .welcome-hub .welcome-h { margin-top: 0; }
-.welcome-pitch .welcome-hub-never { margin-bottom: 6px; color: var(--text); font-weight: 600; }
-.welcome-never { display: flex; flex-direction: column; gap: 4px; color: var(--text); }
-.welcome-never li { display: flex; align-items: flex-start; gap: 8px; }
-.welcome-never .icon { margin-top: 0.3em; color: var(--success); }
-.welcome-pitch .welcome-hub-free {
- margin: 10px 0 0;
- padding-top: 8px;
- border-top: 1px solid var(--border);
- color: var(--text);
- font-weight: 500;
+.welcome-pitch .welcome-docs .welcome-h { margin-top: 0; }
+.welcome-docs ul { display: flex; flex-direction: column; gap: 6px; }
+.welcome-docs li {
+ display: grid;
+ grid-template-columns: auto 10em 1fr;
+ align-items: baseline;
+ gap: 2px 8px;
}
+.welcome-docs .icon { color: var(--accent); }
+.welcome-docs-label { color: var(--text); font-weight: 500; }
+.welcome-docs-links { min-width: 0; overflow-wrap: anywhere; }
.welcome-links {
display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 12px 20px;
- margin-top: 14px;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 10px;
+ text-align: center;
}
.welcome-cta {
- display: inline-flex;
+ display: flex;
align-items: center;
+ justify-content: center;
gap: 8px;
padding: 10px 18px;
border-radius: 6px;
- background: var(--accent);
- color: var(--accent-text);
+ /* Green, so it does not compete with the blue sign-in button above it.
+ #16a34a rather than --success: white text is still legible on it. */
+ background: #16a34a;
+ color: #ffffff;
font-size: 0.95em;
font-weight: 600;
}
-.welcome-cta:hover { background: var(--accent-hover); text-decoration: none; }
+.welcome-cta:hover { background: #15803d; text-decoration: none; }
.welcome-legal { color: var(--text-secondary); font-size: 0.9em; }
.welcome-legal:hover { color: var(--accent); }
@media (max-width: 1000px) {
.welcome { flex-direction: column; align-items: center; gap: 36px; }
- .welcome > .login-card { flex: none; }
+ .welcome-side { flex: none; width: 100%; max-width: 380px; }
.welcome-pitch { flex: none; width: 100%; max-width: 520px; }
}
@media (max-width: 520px) {
.welcome-title { font-size: 1.6em; }
.welcome-uses { grid-template-columns: 1fr; }
+ .welcome-steps { grid-template-columns: 1fr; }
+ .welcome-steps li { flex-direction: row; align-items: center; padding-right: 36px; }
+ .welcome-steps li::after { top: 50%; transform: translateY(-50%); }
+ .welcome-docs li { grid-template-columns: auto 1fr; }
+ .welcome-docs-links { grid-column: 2; }
}
/* ── Form elements ────────────────────────────────────────────────────────── */
@@ -1858,6 +1987,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
justify-content: center;
}
+/* The player opens on top of a show's detail modal, which stays open under it
+ so the next episode is one click away when the player closes. Both are
+ `.video-overlay`; equal values would leave the order to where each happens
+ to sit in the DOM, which is not a decision. Below the operator's match
+ search (210), which only ever opens from the modal, never over the player. */
+.video-overlay.video-player-overlay { z-index: 205; }
+
.video-top-bar {
position: absolute;
top: 0;
@@ -4014,6 +4150,13 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
margin-bottom: 4px;
}
.video-episode-row:hover { border-color: var(--accent); color: var(--accent); }
+/* The episode last started from this modal, so the one to watch next is the
+ row under it. A border and a tint, not a colour change of the text alone,
+ so it does not read as the hover state left behind. */
+.video-episode-row.last-played {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
.video-episode-row .icon { width: 14px; height: 14px; flex-shrink: 0; }
.video-episode-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; }
.video-episode-thumb {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
index 6ae51d1..18ad9d4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
@@ -110,7 +110,7 @@ async function rewrapAllNodes(o) {
try {
await _acWithTimeout(
tp.connect(n.node_id, o.token, g.id, null, null, oldKey,
- o.username, o.userId, null, recoveryKey),
+ o.username, o.userId, null, recoveryKey, undefined, n.pk_node),
30000, 'connect');
if (tp.newNodeBundle) {
// No identity existed on this node — connect just minted one under
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 202db94..546d01b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -305,16 +305,15 @@ window.addEventListener('hashchange', () => {
// The `v: '0.1'` on every other message in this file is the historical value
// and is read by nothing; it is left alone deliberately. The range is
// negotiated once, at the start, not restated per message.
-const MNP_V = '3.1';
-// **Not** raised with it, and that is the whole difference between 3.0 and 3.1.
-// 3.0 was a flag day because a node older than it cannot grant the lease this
-// client opens for every download and upload, so talking to one would mean
-// every transfer failing for a reason the person cannot act on. 3.1 only adds
-// `user_blob_*`: a 3.0 node answers "unknown message type" and the client
-// stores its playlists on the next node it reaches, keeping its own copy
-// meanwhile (docs/playlists.md §6.4). Refusing every 3.0 node over a feature
-// that degrades this quietly would be the flag day nobody needed.
-const MNP_V_MIN = '3.0';
+const MNP_V = '4.0';
+// Raised with it: 4.0 is a flag day. A member now presents a short-lived
+// MNP-audience token in the handshake, not its hub session token — a node
+// older than 4.0 expected the session token, and one newer refuses it, so the
+// two cannot authenticate across the break. This is the C6 rule: no
+// compatibility branch, or the old path (a hub credential handed to a node)
+// stays reachable. The desktop client is gated by client.minimum before it
+// even connects; the browser picks up this build on reload.
+const MNP_V_MIN = '4.0';
// Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's
// check_version): `version_too_old` means *we* are too old for it,
@@ -651,15 +650,34 @@ class MeshBayTransport {
try { this.onConnectProgress(phase); } catch { /* the caller's problem */ }
}
+ // Fetch the short-lived token presented to a node in the handshake. It is a
+ // different credential from the session token used for hub calls: aud=MNP_AUD,
+ // useless at the hub API, so a node operator who captures it gains nothing
+ // there (see meshbay_common/tokens.py). Uses the session token to ask.
+ async _fetchNodeToken(call) {
+ const doFetch = call
+ || (window.MeshBayPlatform && window.MeshBayPlatform.apiFetch) || fetch;
+ const r = await doFetch(`${this._hubUrl}/v1/nodes/mnp-token`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${this._accessToken}`,
+ },
+ body: JSON.stringify({ node_pk: this._nodePkTarget || '' }),
+ });
+ if (!r.ok) throw new Error(`Could not obtain a node token: ${r.status}`);
+ return (await r.json()).mnp_token;
+ }
+
async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username,
- userId, joinCode, recoveryKey, joinNodePk) {
+ userId, joinCode, recoveryKey, joinNodePk, nodePk) {
// Remembered for _reconnectLoop, which calls connect() again with these
// same values (plus a freshly-fetched token and the identity connect()
// itself settles on below) after the WebRTC connection is declared
// "failed" — see the pc.onconnectionstatechange handler further down.
this._connectArgs = {
nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey,
- joinNodePk,
+ joinNodePk, nodePk,
};
this._lastToken = jwtToken;
// The constructor sets this once from whatever token the caller had at
@@ -677,6 +695,10 @@ class MeshBayTransport {
this._recoveryKey = recoveryKey || null;
this._username = username || null;
this._userId = userId || null;
+ // The key of the node we mean to reach, from the hub's node list. The MNP
+ // token is bound to it (E10) so it cannot be replayed to another node. It is
+ // the *expected* key; `this.nodePk` below is the one the node then proves.
+ this._nodePkTarget = nodePk || '';
// The group this connection is for. Kept on the instance because the
// handshake is not the only thing that needs it any more: device_hello and
// the chat envelope both bind to it, and both run outside connect()'s scope.
@@ -896,11 +918,21 @@ class MeshBayTransport {
// recorded handshake_ack could be replayed by an impersonating peer.
this._nonceClient = crypto.getRandomValues(new Uint8Array(32));
+ // The member authenticates to the node with a short-lived MNP token, never
+ // its hub session token. A node operator holds whatever is presented here,
+ // and the session token opens the hub API — so presenting it would hand an
+ // operator a live credential for the member (audience-bound, see
+ // meshbay_common/tokens.py). Fetched per connect and per reconnect with the
+ // session token (`this._accessToken`), so it always carries current group
+ // membership and a fresh expiry. Signaling above still uses the session
+ // token, because that is a hub call.
+ const nodeToken = await this._fetchNodeToken(call);
+
const reply = await this._sendAndWait({
type: 'handshake',
v: MNP_V,
v_min: MNP_V_MIN,
- token: jwtToken,
+ token: nodeToken,
group_id: groupId || '',
nonce: window.MeshBayCrypto.b64encode(this._nonceClient),
});
@@ -1293,7 +1325,7 @@ class MeshBayTransport {
ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw,
this._sessionKeys, args.bundleKey, args.username,
args.userId, args.joinCode, undefined,
- args.joinNodePk);
+ args.joinNodePk, args.nodePk);
} finally {
this._inReconnectAttempt = false;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index 124033e..9e2db1c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -619,6 +619,13 @@ function VideoDetailModal({
const [selectedSeason, setSelectedSeason] = useState(null);
useEffect(() => { setSelectedSeason(defaultSeason(show)); }, [show]);
+ // A show's modal stays open under the player (PosterGrid's onPlay), so
+ // closing the player lands back here. Marking the episode just started is
+ // what makes "the next one" the row under the mark, rather than a row the
+ // reader has to find again by its number.
+ const [lastPlayedId, setLastPlayedId] = useState(null);
+ useEffect(() => { setLastPlayedId(null); }, [show]);
+
const showMultiSeason = Boolean(show && show.seasons.length > 1);
const seasonMeta = useSeasonMeta(
transportRef, confident ? meta.tmdb_id : null, selectedSeason,
@@ -708,7 +715,10 @@ function VideoDetailModal({
</div>
`}
${s.episodes.map((ep) => html`
- <button class="video-episode-row" key=${ep.id} onClick=${() => onPlay(ep)}>
+ <button key=${ep.id}
+ class="video-episode-row ${ep.id === lastPlayedId ? 'last-played' : ''}"
+ aria-current=${ep.id === lastPlayedId ? 'true' : undefined}
+ onClick=${() => { setLastPlayedId(ep.id); onPlay(ep); }}>
<${LazyTile} cls="video-episode-thumb-slot">
<${MediaThumb} thumbHash=${ep.thumb_hash} alt=${ep.display_title || ep.name}
cls="video-episode-thumb" transportRef=${ep._tRef || transportRef} gekRef=${ep._gRef || gekRef}
@@ -857,13 +867,18 @@ function PosterGrid({
</${LazyTile}>
`; })}
</div>
+ ${/* A show's modal is left open under the player: watching episode after
+ episode used to mean reopening the show and picking the season again
+ every time. A film has nothing left to pick, so its modal still
+ closes. The player stacks above it by its own z-index (style.css,
+ .video-player-overlay), not by DOM order. */''}
${detail && html`
<${VideoDetailModal} title=${detail.title} meta=${detailMeta}
repEntry=${detail.repEntry} show=${detail.show}
transportRef=${detailTRef} gekRef=${detailGRef} isNodeAdmin=${isNodeAdmin}
tmdbEnabled=${tmdbEnabled}
onClose=${() => setDetail(null)}
- onPlay=${(entry) => { setDetail(null); onPreview(entry); }} />
+ onPlay=${(entry) => { if (!detail.show) setDetail(null); onPreview(entry); }} />
`}
`;
}
@@ -976,6 +991,22 @@ function VideoApp({
useEffect(() => { setMode(loadViewMode()); }, [groupId]);
useEffect(() => { setFilter(''); setTypeFilter('all'); }, [groupId]);
+ // When the operator changes the node's TMDB language, the node drops its
+ // metadata cache and refetches in the new language, and — until a language
+ // is set — answers nothing at all rather than querying in English (§9.7).
+ // Tell every mounted tile to redo its media_meta_req and drop the
+ // show-level meta already merged here, so the grid switches language (or
+ // fills in for the first time, right after the operator picks one) without
+ // a page reload. Skip the initial mount: the language is already right then,
+ // and bumping would restorm TMDB on every open of the Videos tab.
+ const tmdbLanguage = tmdbConfig ? (tmdbConfig.language || '') : '';
+ const firstLangRef = useRef(true);
+ useEffect(() => {
+ if (firstLangRef.current) { firstLangRef.current = false; return; }
+ setMetaByGroup({});
+ bumpMediaMetaGeneration();
+ }, [tmdbLanguage]);
+
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
const videoEntries = availableEntries || entries;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
index 6eaacc5..2b274b5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -1596,7 +1596,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
}, []);
return html`
- <div class="video-overlay" onClick=${(e) => {
+ <div class="video-overlay video-player-overlay" onClick=${(e) => {
if (e.target.classList.contains('video-overlay')) onClose();
}}>
<div class="video-top-bar">
diff --git a/packages/meshbay-hub/tests/harness/layout_probe.py b/packages/meshbay-hub/tests/harness/layout_probe.py
index 65b3083..0abbda6 100644
--- a/packages/meshbay-hub/tests/harness/layout_probe.py
+++ b/packages/meshbay-hub/tests/harness/layout_probe.py
@@ -81,7 +81,7 @@ RECORDS = []
def main() -> int:
widths = [int(w) for w in sys.argv[1].split(",")]
- fragment = Path(sys.argv[2]).read_text()
+ fragment = Path(sys.argv[2]).read_text(encoding="utf-8")
selectors = sys.argv[3:]
class H(http.server.BaseHTTPRequestHandler):
diff --git a/packages/meshbay-hub/tests/harness/lazy_failure_probe.py b/packages/meshbay-hub/tests/harness/lazy_failure_probe.py
new file mode 100644
index 0000000..357529a
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/lazy_failure_probe.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""
+What `lazy()` shows when the module it asks for answers 404.
+
+The shape found live: a tab opened before a hub deploy asks for its not-yet-
+loaded modules under the previous `/a/<hash>/` prefix, which the new hub no
+longer serves. Every lazily loaded view — Search, Videos, Music, Photos, the
+video player — sat on its spinner for good, with an empty console. This
+renders the shipped `lazy.js` against a module that does not exist, and one
+that does, and reads back what is on the page.
+
+ lazy_failure_probe.py
+"""
+
+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 = 8763
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<div id="plain"></div><div id="framed"></div><div id="fine"></div>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale } from '/i18n.js';
+import { lazy } from '/lazy.js';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const errors = [];
+const origError = console.error;
+console.error = (...a) => { errors.push(a.map(String).join(' ')); origError(...a); };
+
+const read = (id) => {
+ const root = document.getElementById(id);
+ return {
+ spinner: !!root.querySelector('.spinner'),
+ notice: (root.querySelector('.lazy-failed p') || {}).textContent || null,
+ buttons: [...root.querySelectorAll('.lazy-failed button')].map((b) => b.textContent.trim()),
+ overlay: !!root.querySelector('.video-player-overlay .lazy-failed'),
+ text: root.textContent.trim(),
+ };
+};
+
+(async () => {
+ try {
+ await initLocale();
+ // The stale tab's request: a module under a prefix nobody serves.
+ const Gone = lazy(() => import('/a/0000000000/gone.js'), 'Gone');
+ const frame = (c) => html`<div class="video-overlay video-player-overlay">${c}</div>`;
+ const GoneOverlay = lazy(() => import('/a/0000000000/player.js'), 'Player',
+ frame(html`<p class="page-message"><span class="spinner"></span></p>`), frame);
+ // A module that exists still renders as itself.
+ const Fine = lazy(() => import('/icon.js'), 'Icon');
+
+ let closed = 0;
+ render(html`<${Gone} />`, document.getElementById('plain'));
+ render(html`<${GoneOverlay} onClose=${() => { closed += 1; }} />`,
+ document.getElementById('framed'));
+ render(html`<${Fine} name="close" />`, document.getElementById('fine'));
+ await sleep(1500);
+
+ const out = { plain: read('plain'), framed: read('framed'), errors,
+ fine: !!document.querySelector('#fine svg, #fine .icon') };
+ const btns = document.querySelectorAll('#framed .lazy-failed button');
+ if (btns[1]) btns[1].click();
+ out.closed = closed;
+ parent.postMessage(out, '*');
+ } catch (err) {
+ parent.postMessage({ error: String((err && err.stack) || err) }, '*');
+ }
+})();
+</script></body></html>"""
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><iframe src="/case" style="width:900px;height:700px;border:0"></iframe>
+<script>
+addEventListener('message', (e) => fetch('/log', { method: 'POST', body: JSON.stringify(e.data) }));
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.append(json.loads(self.rfile.read(length).decode()))
+ else:
+ self.rfile.read(length)
+ 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")
+ elif path == "/case":
+ self._send(FRAME.encode(), "text/html; charset=utf-8")
+ else:
+ 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
+ self._send(asset.read_bytes(),
+ "text/css" if asset.suffix == ".css"
+ else "text/javascript" if asset.suffix == ".js"
+ else "application/octet-stream")
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ 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}", "--window-size=900,700",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ deadline = time.time() + 60
+ while not RECORDS and time.time() < deadline:
+ time.sleep(0.2)
+ proc.terminate()
+ proc.wait(timeout=20)
+ if not RECORDS:
+ print("the page never reported", file=sys.stderr)
+ return 1
+ print(json.dumps(RECORDS[0]))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/harness/scroll_probe.py b/packages/meshbay-hub/tests/harness/scroll_probe.py
index ae407b8..55d5b2b 100644
--- a/packages/meshbay-hub/tests/harness/scroll_probe.py
+++ b/packages/meshbay-hub/tests/harness/scroll_probe.py
@@ -32,7 +32,7 @@ STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
# group-page refactor.
APP = STATIC / "chat-app.js"
PORT = 8736
-FRAG = Path(sys.argv[1]).read_text()
+FRAG = Path(sys.argv[1]).read_text(encoding="utf-8")
HEIGHTS = ([int(h) for h in sys.argv[2].split(",")]
if len(sys.argv) > 2 else [700, 900, 1200])
diff --git a/packages/meshbay-hub/tests/harness/video_series_probe.py b/packages/meshbay-hub/tests/harness/video_series_probe.py
new file mode 100644
index 0000000..f161c30
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/video_series_probe.py
@@ -0,0 +1,266 @@
+#!/usr/bin/env python3
+"""
+What is on screen after watching one episode of a show, and closing the player.
+
+Playing an episode used to close the show's detail modal, so the next episode
+meant opening the show again and picking the season again, every time. The
+modal now stays open under the player. That is a claim about three components
+at once — `PosterGrid` deciding whether to close it, `GroupPage` mounting the
+player beside it, and the stylesheet deciding which of two `.video-overlay`s is
+on top — so this renders the shipped `GroupPage` against a stub node and walks
+it as a reader would, reading back what is on the page after each step.
+
+A film goes through the same modal and must still close it: it has nothing left
+to pick from.
+
+ video_series_probe.py
+"""
+
+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 = 8761
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div></nav>
+<div class="layout"><main class="main"><div id="root"></div></main></div>
+<script>
+const ENTRIES = [];
+let n = 0;
+// Two seasons of three episodes, so there is a season to pick and a
+// "next episode" after the one played. No thumbnails: a card with no frame to
+// fetch is ready at once.
+for (let s = 1; s <= 2; s++) {
+ for (let e = 1; e <= 3; e++) {
+ ENTRIES.push({ id: 'ep' + s + e, name: 'Some.Show.S0' + s + 'E0' + e + '.mkv',
+ display_title: 'Some Show', path: 'videos/Some Show/Season ' + s,
+ type: 'video', season: s, episode: e, duration: 2600, size: 1024,
+ added_at: 1750000000 + (++n) });
+ }
+}
+ENTRIES.push({ id: 'film', name: 'A.Film.mkv', display_title: 'A Film',
+ path: 'videos/films', type: 'video', duration: 6000, size: 1024,
+ added_at: 1750000000 + (++n) });
+
+const ACK = {
+ is_node_admin: false,
+ enabled_apps: ['video'],
+ tmdb_enabled: true, tmdb_language: 'en-US',
+ video_directories: ['videos'], music_directories: [], photo_directories: [],
+};
+
+window.MeshBayTransport = function () {
+ const self = {
+ connected: false, memberRole: 'member', supportsAppOps: true,
+ sessionKeys: null, gekRaw: null,
+ newNodeBundle: null, newNodeBundleRecovery: null,
+ async connect() { self.connected = true; return ACK; },
+ async fetchIndex() {
+ return { entries: ENTRIES, dirs: ['videos'],
+ roots: [{ name: 'videos', available: true, writable: false,
+ removable: false }] };
+ },
+ // Unmatched: the modal still opens for both, and nothing here depends on
+ // what TMDB would have said.
+ async fetchMediaMeta() { return { confidence: 0 }; },
+ addReconnectListener() { return () => {}; },
+ close() {},
+ };
+ // Everything else the player asks for never answers: it sits on its
+ // spinner, which is all a stacking and a close need.
+ return new Proxy(self, {
+ get(target, prop) {
+ if (prop in target) return target[prop];
+ if (typeof prop === 'string' && prop.startsWith('on')) return undefined;
+ if (typeof prop === 'symbol') return undefined;
+ return () => new Promise(() => {});
+ },
+ set(target, prop, value) { target[prop] = value; return true; },
+ });
+};
+</script>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale } from '/i18n.js';
+import { GroupPage } from '/group-page.js';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const $ = (sel) => document.querySelector(sel);
+const $$ = (sel) => [...document.querySelectorAll(sel)];
+async function until(pred, what) {
+ for (let i = 0; i < 100; i++) { if (pred()) return; await sleep(50); }
+ throw new Error('timed out waiting for ' + what);
+}
+
+try { localStorage.removeItem('meshbay_video_view_mode'); } catch {}
+
+const player = () => $('.video-player-overlay');
+// Which overlay a click in the middle of the window would reach.
+const topmost = () => {
+ const el = document.elementFromPoint(innerWidth / 2, innerHeight / 2);
+ if (!el) return null;
+ if (el.closest('.video-player-overlay')) return 'player';
+ if (el.closest('.video-detail') || el.closest('.video-overlay')) return 'detail';
+ return 'page';
+};
+const state = () => ({
+ detail: !!$('.video-detail'),
+ player: !!player(),
+ topmost: topmost(),
+ season: ($('.video-season-current') || {}).textContent?.trim() || null,
+ marked: $$('.video-episode-row.last-played').map(
+ (r) => r.querySelector('.video-episode-label').textContent.replace(/\s+/g, ' ').trim()),
+ rows: $$('.video-episode-row').map(
+ (r) => r.querySelector('.video-episode-label').textContent.replace(/\s+/g, ' ').trim()),
+});
+
+(async () => {
+ const out = {};
+ try {
+ await initLocale();
+ render(html`<${GroupPage} groupId="g1" token="t" username="me" userId="u1"
+ group=${{ id: 'g1', name: 'a group', owner_username: 'me', is_admin: false }}
+ userPrefs=${{ default_tab: 'video', media_page_size: '50' }} />`,
+ document.getElementById('root'));
+
+ await until(() => $$('.video-card-title').length === 2, 'two cards');
+ const card = (title) => $$('.video-card').find(
+ (c) => c.querySelector('.video-card-title').textContent.trim().startsWith(title));
+
+ // The show: open it, go to season 2, play its second episode.
+ card('Some Show').click();
+ await until(() => $('.video-season-trigger'), 'the show modal');
+ $('.video-season-trigger').click();
+ await until(() => $$('.video-season-option').length === 2, 'the season menu');
+ $$('.video-season-option')[1].click();
+ await sleep(100);
+ $$('.video-episode-row')[1].click();
+ await until(() => player() && player().querySelector('.video-top-bar'), 'the player');
+ await sleep(100);
+ out.playing = state();
+
+ // Esc is how most people leave a player.
+ dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ await until(() => !player(), 'the player to close on Esc');
+ await sleep(100);
+ out.afterEscape = state();
+
+ // Next episode, straight from the modal, then the player's own close button.
+ $$('.video-episode-row')[2].click();
+ await until(() => player() && player().querySelector('.video-top-bar .video-close'),
+ 'the player again');
+ const closes = player().querySelectorAll('.video-top-bar .video-close');
+ closes[closes.length - 1].click();
+ await until(() => !player(), 'the player to close on its button');
+ await sleep(100);
+ out.afterClose = state();
+
+ // Closing the modal itself still closes it.
+ $('.video-detail .video-top-bar .video-close').click();
+ await sleep(100);
+ out.afterModalClose = state();
+
+ // Reopening the show starts afresh: no mark carried over from last time.
+ card('Some Show').click();
+ await until(() => $('.video-detail'), 'the show modal again');
+ await sleep(100);
+ out.reopened = state();
+ $('.video-detail .video-top-bar .video-close').click();
+ await sleep(100);
+
+ // A film: its modal closes when it starts, as it always did.
+ card('A Film').click();
+ await until(() => $('.video-detail .admin-btn'), 'the film modal');
+ $('.video-detail .admin-btn').click();
+ await until(() => player(), 'the film player');
+ await sleep(100);
+ out.film = state();
+
+ parent.postMessage(out, '*');
+ } catch (err) {
+ parent.postMessage({ ...out, error: String((err && err.stack) || err) }, '*');
+ }
+})();
+</script></body></html>"""
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><iframe src="/case" style="width:1100px;height:800px;border:0"></iframe>
+<script>
+addEventListener('message', (e) => fetch('/log', { method: 'POST', body: JSON.stringify(e.data) }));
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.append(json.loads(self.rfile.read(length).decode()))
+ else:
+ self.rfile.read(length)
+ 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")
+ elif path == "/case":
+ self._send(FRAME.encode(), "text/html; charset=utf-8")
+ elif path == "/v1/groups/g1/nodes":
+ self._send(b'{"nodes": [{"node_id": "n1"}]}', "application/json")
+ else:
+ 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
+ self._send(asset.read_bytes(),
+ "text/css" if asset.suffix == ".css"
+ else "text/javascript" if asset.suffix == ".js"
+ else "application/octet-stream")
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ 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}", "--window-size=1100,900",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ deadline = time.time() + 90
+ while not RECORDS and time.time() < deadline:
+ time.sleep(0.2)
+ proc.terminate()
+ proc.wait(timeout=20)
+ if not RECORDS:
+ print("the page never reported", file=sys.stderr)
+ return 1
+ print(json.dumps(RECORDS[0]))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_account_pinning.py b/packages/meshbay-hub/tests/test_account_pinning.py
index 529ebd5..ebd29bd 100644
--- a/packages/meshbay-hub/tests/test_account_pinning.py
+++ b/packages/meshbay-hub/tests/test_account_pinning.py
@@ -102,11 +102,11 @@ def _entry(user, pk_ed, pk_x="cGtY", *, added_by="", sk_signer=None,
def _verify(devices, node_pk=NODE_PK):
with tempfile.TemporaryDirectory() as tmp:
h = Path(tmp) / "h.js"
- h.write_text(_HARNESS)
+ h.write_text(_HARNESS, encoding="utf-8")
payload = Path(tmp) / "in.json"
payload.write_text(json.dumps(
{"payload": {"devices": devices, "node_pk": node_pk},
- "node_pk": node_pk}))
+ "node_pk": node_pk}), encoding="utf-8")
run = subprocess.run(
["node", str(h), str(CRYPTO), transport_argv(), str(payload)],
capture_output=True, timeout=60)
diff --git a/packages/meshbay-hub/tests/test_argon2_off_loop.py b/packages/meshbay-hub/tests/test_argon2_off_loop.py
index 5c25a8e..ae6cc08 100644
--- a/packages/meshbay-hub/tests/test_argon2_off_loop.py
+++ b/packages/meshbay-hub/tests/test_argon2_off_loop.py
@@ -28,7 +28,7 @@ def test_nothing_derives_argon2_on_the_event_loop():
for path in SRC.rglob("*.py"):
if path.name == "auth.py":
continue
- for n, line in enumerate(path.read_text().splitlines(), 1):
+ for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if direct.search(line):
offenders.append(f"{path.relative_to(SRC)}:{n}: {line.strip()}")
assert not offenders, (
diff --git a/packages/meshbay-hub/tests/test_asset_versioning.py b/packages/meshbay-hub/tests/test_asset_versioning.py
index aa2dc6d..7057311 100644
--- a/packages/meshbay-hub/tests/test_asset_versioning.py
+++ b/packages/meshbay-hub/tests/test_asset_versioning.py
@@ -135,7 +135,7 @@ def test_a_new_file_moves_the_fingerprint():
before = _asset_version()
extra = STATIC_DIR / "locales" / "zz-test-only.js"
try:
- extra.write_text("export default {};\n")
+ extra.write_text("export default {};\n", encoding="utf-8")
assert _asset_version() != before
finally:
extra.unlink()
diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py
index 4f5cbfb..24d85a0 100644
--- a/packages/meshbay-hub/tests/test_availability_between_members.py
+++ b/packages/meshbay-hub/tests/test_availability_between_members.py
@@ -482,13 +482,14 @@ async def test_changing_your_address_cannot_mail_strangers_at_will(
user = await _make_user(client, "av_mailer")
headers = {"Authorization": f"Bearer {user['token']}"}
+ ak = base64.b64encode(b"k" * 32).decode() # the auth_key _make_user signs up with
r = await client.patch("/v1/users/me", headers=headers,
- json={"email": "a-stranger@example.test"})
+ json={"email": "a-stranger@example.test", "auth_key": ak})
assert r.status_code == 200, r.text
assert len(sent) == 1
r = await client.patch("/v1/users/me", headers=headers,
- json={"email": "another-stranger@example.test"})
+ json={"email": "another-stranger@example.test", "auth_key": ak})
assert r.status_code == 429, r.text
assert len(sent) == 1, "the hub mailed a second stranger on demand"
@@ -536,7 +537,7 @@ def test_no_mail_is_sent_from_the_event_loop():
for path in root.rglob("*.py"):
if path.name == "mail.py":
continue
- for n, line in enumerate(path.read_text().splitlines(), 1):
+ for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if direct_call.search(line):
offenders.append(f"{path.name}:{n}: {line.strip()}")
assert not offenders, (
diff --git a/packages/meshbay-hub/tests/test_browser_idle_signout.py b/packages/meshbay-hub/tests/test_browser_idle_signout.py
index 50f7f04..454874a 100644
--- a/packages/meshbay-hub/tests/test_browser_idle_signout.py
+++ b/packages/meshbay-hub/tests/test_browser_idle_signout.py
@@ -84,7 +84,7 @@ def outcome():
pytest.skip("node is not available")
proc = subprocess.run(
[NODE, "--input-type=module", "--eval", HARNESS, IDLE.as_uri()],
- capture_output=True, text=True, timeout=30)
+ capture_output=True, text=True, encoding="utf-8", timeout=30)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip().splitlines()[-1])
diff --git a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
index 27e10d4..c62aace 100644
--- a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
+++ b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
@@ -77,19 +77,19 @@ const argon2 = require(process.argv[3]);
def js_hashes(tmp_path_factory):
d = tmp_path_factory.mktemp("kdf")
harness = d / "harness.cjs"
- harness.write_text(_HARNESS)
+ harness.write_text(_HARNESS, encoding="utf-8")
vectors = [
{"username": u, "password": p,
"mem": MEM_KIB, "time": TIME_COST, "lanes": LANES}
for u in CASES for p in PASSWORDS
]
payload = d / "vectors.json"
- payload.write_text(json.dumps(vectors))
+ payload.write_text(json.dumps(vectors), encoding="utf-8")
proc = subprocess.run(
["node", str(harness), str(VENDOR / "argon2.wasm"),
str(VENDOR / "argon2.min.js"), str(payload)],
- capture_output=True, text=True, timeout=300,
+ capture_output=True, text=True, encoding="utf-8", timeout=300,
)
if proc.returncode != 0:
pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}")
@@ -124,7 +124,7 @@ def test_parameters_still_match_the_client():
The numbers live in keyderive.js; this test is the second copy. Tuning one
without the other orphans every bundle already written, so make it fail.
"""
- source = (STATIC / "keyderive.js").read_text()
+ source = (STATIC / "keyderive.js").read_text(encoding="utf-8")
assert f"ARGON2_MEM_KIB = {MEM_KIB}" in source
assert f"ARGON2_TIME = {TIME_COST}" in source
assert f"ARGON2_LANES = {LANES}" in source
diff --git a/packages/meshbay-hub/tests/test_captcha_host_check.py b/packages/meshbay-hub/tests/test_captcha_host_check.py
index 778009f..a0ff509 100644
--- a/packages/meshbay-hub/tests/test_captcha_host_check.py
+++ b/packages/meshbay-hub/tests/test_captcha_host_check.py
@@ -208,7 +208,7 @@ def test_hub_toml_carries_the_allowed_hosts(tmp_path):
'[captcha]\n'
'site_key = "6Lsite"\n'
'secret_key = "6Lsecret"\n'
- 'allowed_hosts = ["meshbay.org", " meshbay ", "", "localhost"]\n')
+ 'allowed_hosts = ["meshbay.org", " meshbay ", "", "localhost"]\n', encoding="utf-8")
cfg = load_config(cfg_file)
@@ -222,7 +222,8 @@ def test_a_hub_toml_without_the_key_checks_nothing(tmp_path):
"""The upgrade path. A hub that never heard of this setting keeps the
behaviour it has, with reCAPTCHA doing the origin check."""
cfg_file = tmp_path / "hub.toml"
- cfg_file.write_text('[captcha]\nsite_key = "6Lsite"\nsecret_key = "6Lsecret"\n')
+ cfg_file.write_text('[captcha]\nsite_key = "6Lsite"\nsecret_key = "6Lsecret"\n',
+ encoding="utf-8")
assert load_config(cfg_file).captcha.host_check is None
@@ -231,10 +232,10 @@ def test_the_unattributed_flag_comes_from_the_config(tmp_path, monkeypatch):
cfg_file = tmp_path / "hub.toml"
cfg_file.write_text(
'[captcha]\nsite_key = "k"\nsecret_key = "s"\n'
- 'allowed_hosts = ["meshbay.org"]\nallow_unattributed_host = true\n')
+ 'allowed_hosts = ["meshbay.org"]\nallow_unattributed_host = true\n', encoding="utf-8")
assert load_config(cfg_file).captcha.allow_unattributed_host is True
- cfg_file.write_text('[captcha]\nsite_key = "k"\nsecret_key = "s"\n')
+ cfg_file.write_text('[captcha]\nsite_key = "k"\nsecret_key = "s"\n', encoding="utf-8")
assert load_config(cfg_file).captcha.allow_unattributed_host is False, (
"the default has to stay off — it is the looser of the two")
diff --git a/packages/meshbay-hub/tests/test_cast_subtitles.py b/packages/meshbay-hub/tests/test_cast_subtitles.py
index fdf7fcc..bdc279b 100644
--- a/packages/meshbay-hub/tests/test_cast_subtitles.py
+++ b/packages/meshbay-hub/tests/test_cast_subtitles.py
@@ -66,7 +66,7 @@ def _run(tmp_path, body: str, *args: str):
+ "\n" + body, encoding="utf-8")
proc = subprocess.run(
["node", str(script), *args],
- capture_output=True, text=True, timeout=30)
+ capture_output=True, text=True, encoding="utf-8", timeout=30)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@@ -260,7 +260,7 @@ def served(tmp_path_factory):
script.write_text(RELAY_SCRIPT, encoding="utf-8")
proc = subprocess.run(
["node", str(script), str(RELAY)],
- capture_output=True, text=True, timeout=60)
+ capture_output=True, text=True, encoding="utf-8", timeout=60)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip().splitlines()[-1])
@@ -363,7 +363,7 @@ def loaded(tmp_path_factory):
script.write_text(CHROMECAST_SCRIPT, encoding="utf-8")
proc = subprocess.run(
["node", str(script), str(CHROMECAST)],
- capture_output=True, text=True, timeout=60)
+ capture_output=True, text=True, encoding="utf-8", timeout=60)
if proc.returncode != 0:
pytest.skip(f"cast-chromecast.js is not loadable here: {proc.stderr}")
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_challenge_signature_client.py b/packages/meshbay-hub/tests/test_challenge_signature_client.py
index e3c33ac..b904698 100644
--- a/packages/meshbay-hub/tests/test_challenge_signature_client.py
+++ b/packages/meshbay-hub/tests/test_challenge_signature_client.py
@@ -93,11 +93,11 @@ def test_the_browser_holds_the_node_to_its_challenge(tmp_path):
"garbage for a signature": (case(sig=b"\x00" * 64), "refused"),
}
harness = tmp_path / "harness.js"
- harness.write_text(_HARNESS)
+ harness.write_text(_HARNESS, encoding="utf-8")
payload = tmp_path / "cases.json"
- payload.write_text(json.dumps([c for c, _ in cases.values()]))
+ payload.write_text(json.dumps([c for c, _ in cases.values()]), encoding="utf-8")
proc = subprocess.run(["node", str(harness), str(STATIC), str(payload), transport_argv()],
- capture_output=True, text=True, timeout=60)
+ capture_output=True, text=True, encoding="utf-8", timeout=60)
assert proc.returncode == 0, proc.stderr
got = dict(zip(cases, json.loads(proc.stdout)))
assert got == {name: want for name, (_, want) in cases.items()}
diff --git a/packages/meshbay-hub/tests/test_chat_scroll_bottom.py b/packages/meshbay-hub/tests/test_chat_scroll_bottom.py
index 7b66c00..42b1055 100644
--- a/packages/meshbay-hub/tests/test_chat_scroll_bottom.py
+++ b/packages/meshbay-hub/tests/test_chat_scroll_bottom.py
@@ -42,7 +42,7 @@ pytestmark = pytest.mark.skipif(not CHAT.exists(), reason="SPA sources not prese
def _chat_panel_source() -> str:
- src = CHAT.read_text()
+ src = CHAT.read_text(encoding="utf-8")
start = src.index("\nfunction ChatPanel(")
end = src.find("\nfunction ", start + 1)
return src[start:end if end != -1 else len(src)]
diff --git a/packages/meshbay-hub/tests/test_client_version_gate.py b/packages/meshbay-hub/tests/test_client_version_gate.py
index 4dc9b98..91cb99a 100644
--- a/packages/meshbay-hub/tests/test_client_version_gate.py
+++ b/packages/meshbay-hub/tests/test_client_version_gate.py
@@ -32,7 +32,7 @@ pytestmark = pytest.mark.skipif(
def _lift(name: str) -> str:
- src = MAIN.read_text()
+ src = MAIN.read_text(encoding="utf-8")
cut = src[src.index(name):]
return cut[:cut.index("\n}\n") + 2]
@@ -69,8 +69,8 @@ out.compare = [
compareVersions('nonsense', '1.1.0'),
];
console.log(JSON.stringify(out));
-""")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@@ -133,7 +133,7 @@ def test_a_first_run_with_no_hub_yet_is_not_stopped(tmp_path):
def test_the_gate_runs_before_the_window_is_built():
"""A window that opens and then cannot connect is the failure this
replaces, so the order is the whole point."""
- src = MAIN.read_text()
+ src = MAIN.read_text(encoding="utf-8")
ready = src[src.index("app.whenReady().then("):]
ready = ready[:ready.index("createWindow();")]
assert "await refuseIfTooOld()" in ready, (
diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py
index 395053b..c8c68a9 100644
--- a/packages/meshbay-hub/tests/test_downloads.py
+++ b/packages/meshbay-hub/tests/test_downloads.py
@@ -26,7 +26,7 @@ pytestmark = pytest.mark.skipif(
def _run(body, tmp_path):
module = tmp_path / "downloads.mjs"
- module.write_text(DOWNLOADS.read_text())
+ module.write_text(DOWNLOADS.read_text(encoding="utf-8"), encoding="utf-8")
script = tmp_path / "case.mjs"
script.write_text(
# A localStorage good enough for a preference, so the module can be
@@ -36,12 +36,12 @@ def _run(body, tmp_path):
" getItem: k => (store.has(k) ? store.get(k) : null),\n"
" setItem: (k, v) => store.set(k, String(v)),\n"
"};\n"
- f"const M = await import('{module.as_posix()}');\n"
+ f"const M = await import('{module.as_uri()}');\n"
"const out = [];\n"
"const say = (...a) => out.push(...a);\n"
f"{body}\n"
- "console.log(JSON.stringify(out));\n")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ "console.log(JSON.stringify(out));\n", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@@ -104,7 +104,7 @@ def test_the_open_action_reads_the_file_back(tmp_path):
manager either. It is only offered for a file written into a granted folder,
since that is the one a page can read back.
"""
- src = DOWNLOADS.read_text()
+ src = DOWNLOADS.read_text(encoding="utf-8")
target = src[src.index("export async function openTarget"):]
assert "getFile()" in target and "window.open(" in target
assert "revokeObjectURL" in target, "the blob URL must not be leaked"
@@ -122,7 +122,7 @@ def test_the_worker_only_answers_its_own_urls():
service worker that answers more than it should is a cache bug waiting to
happen.
"""
- src = SW.read_text()
+ src = SW.read_text(encoding="utf-8")
assert "startsWith(PREFIX)" in src
assert "self.location.origin" in src, "cross-origin requests must fall through"
# The API, not the word: the file explains in prose that it caches nothing.
@@ -131,7 +131,7 @@ def test_the_worker_only_answers_its_own_urls():
def test_the_download_is_announced_as_an_attachment():
- src = SW.read_text()
+ src = SW.read_text(encoding="utf-8")
assert "Content-Disposition" in src and "attachment" in src
assert "filename*=UTF-8''" in src, "a name with accents would be mangled"
assert "Content-Length" in src
@@ -142,7 +142,7 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path):
An archive is assembled as it goes and is larger than the files in it.
Announcing the sum of their sizes would truncate the download at that mark.
"""
- src = SW.read_text()
+ src = SW.read_text(encoding="utf-8")
assert "if (entry.size > 0)" in src
# The zip-directory download started in files-app.js (group-page refactor)
@@ -153,7 +153,7 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path):
# became a bare `target = ...` inside a try when _openDownloadTarget gained
# the ability to refuse an oversized download (test_memory_ceiling.py).
# What this test is about -- the `0` -- did not move.
- app = (STATIC / "file-utils.js").read_text()
+ app = (STATIC / "file-utils.js").read_text(encoding="utf-8")
# Anchored on the argument list, not on the function name: the call became
# `_openTargetInTurn(suggested, …)` when target openings were serialised.
# The `0` this test is about did not move.
@@ -169,7 +169,7 @@ def test_backpressure_is_real(tmp_path):
is transferred gives `writer.write()` something to wait on; posting chunks
to a port would queue them in memory and look identical from here.
"""
- src = DOWNLOADS.read_text()
+ src = DOWNLOADS.read_text(encoding="utf-8")
fn = src[src.index("export async function openStreamedDownload"):]
assert "new TransformStream()" in fn
# The transfer list may carry more than the stream — a reply port rides
@@ -210,13 +210,13 @@ def test_the_streamed_path_gives_up_rather_than_blocking_for_ever():
So the worker confirms that it actually answered, and this path reports
failure instead of returning a sink nobody drains.
"""
- src = DOWNLOADS.read_text()
+ src = DOWNLOADS.read_text(encoding="utf-8")
fn = src[src.index("export async function openStreamedDownload"):]
assert "mbdl-serving" in fn, "the worker has to confirm it served the request"
assert "Promise.race" in fn, "the confirmation needs a deadline"
assert "writable.abort" in fn, "give up cleanly so the caller can fall back"
- sw = (DOWNLOADS.parent / "sw.js").read_text()
+ sw = (DOWNLOADS.parent / "sw.js").read_text(encoding="utf-8")
assert "mbdl-serving" in sw, "and the worker has to send that confirmation"
@@ -227,7 +227,7 @@ def test_an_uncontrolled_page_is_not_treated_as_ready():
# became a parameter, and `serviceWorker()` no longer contains the words.
# The behaviour itself is executed in test_streamed_download_reliability.py;
# this stays as the cheap guard on the module's shape.
- src = DOWNLOADS.read_text()
+ src = DOWNLOADS.read_text(encoding="utf-8")
section = src[src.index("// ── Streaming to disk"):]
assert "navigator.serviceWorker.controller" in section
assert "controllerchange" in section, (
@@ -251,7 +251,7 @@ def test_an_apostrophe_in_a_name_does_not_lose_the_name(tmp_path):
The real function is lifted out of sw.js and run — a second copy here would
have the same blind spot as the first.
"""
- src = SW.read_text()
+ src = SW.read_text(encoding="utf-8")
fn = src[src.index("function contentDisposition"):]
fn = fn[:fn.index("\n}") + 2]
@@ -263,8 +263,8 @@ for (const name of ["S03E02. Queen's Landing.mp4", 'Caf\\u00e9 (2019).mkv',
out[name] = contentDisposition(name);
}
console.log(JSON.stringify(out));
-""")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
@@ -304,7 +304,7 @@ def test_a_sink_that_stops_consuming_fails_instead_of_hanging(tmp_path):
wrong. Bounding it does not fix whatever stopped the sink — it turns an
unexplainable freeze into a failed transfer that names itself.
"""
- src = (STATIC / "file-utils.js").read_text()
+ src = (STATIC / "file-utils.js").read_text(encoding="utf-8")
fn = src[src.index("async function _writeOrStall"):]
fn = fn[:fn.index("\n}\n") + 2]
@@ -328,8 +328,8 @@ const live = { write: async () => {} };
await _writeOrStall(live, new Uint8Array(4), 0);
out.liveOk = true;
console.log(JSON.stringify(out));
-""")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
assert out["threw"], "a dead sink hung for ever instead of failing"
@@ -358,8 +358,8 @@ def test_the_worker_is_kept_alive_while_it_streams():
clock. What it protects is that the ping exists at all, is cleared on both
exits, and is answered by the worker.
"""
- dl = DOWNLOADS.read_text()
- sw = SW.read_text()
+ dl = DOWNLOADS.read_text(encoding="utf-8")
+ sw = SW.read_text(encoding="utf-8")
assert "SW_KEEPALIVE_MS" in dl and "mbdl-ping" in dl, (
"nothing keeps the worker alive; downloads longer than ~30 s will "
@@ -389,7 +389,7 @@ def _turn_harness(tmp_path, name, body, *, picker=True, budget_ms=90000):
the budget is supplied here, so a case about the budget need not wait a
minute and a half for it.
"""
- src = (STATIC / "file-utils.js").read_text()
+ src = (STATIC / "file-utils.js").read_text(encoding="utf-8")
def lift(decl):
cut = src[src.index(decl):]
@@ -422,8 +422,8 @@ const TARGET_QUEUE_BUDGET_MS = {budget_ms};
""" + lift("function _openTargetInTurn") + lift("function _waitBriefly") + f"""
{body}
console.log(JSON.stringify(out));
-""")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@@ -518,7 +518,7 @@ def test_a_pause_falls_between_chunks_and_resumes_at_one(tmp_path):
rule this repo follows for the video player: model the environment, never
the code under test.
"""
- src = (STATIC / "file-utils.js").read_text()
+ src = (STATIC / "file-utils.js").read_text(encoding="utf-8")
fn = src[src.index("async function pipelinedDownload"):]
fn = fn[:fn.index("\n}\n") + 2]
@@ -558,8 +558,8 @@ await pipelinedDownload({}, 'k', 'file', 10, () => {}, {}, signal, '',
out.resumeFrom);
out.writtenAfterResume = written.slice();
console.log(JSON.stringify(out));
-""")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py b/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py
new file mode 100644
index 0000000..697e6f9
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py
@@ -0,0 +1,55 @@
+"""Changing the address on file requires the passphrase, not merely a token.
+
+A member hands its hub access token to every node it connects to (the MNP
+handshake), so a node operator holds a live bearer token for that member.
+`PATCH /v1/users/me {email}` used to need only that token, and the confirmation
+code goes to the *new* address — so an operator could point the account's e-mail
+at their own inbox, confirm it, and then use the passphrase-reset path to take
+the account over. The passphrase (as the derived auth_key, which is all the hub
+ever sees) is now required, exactly as for a passphrase change or an account
+deletion.
+"""
+
+import pytest
+
+
+async def _account(client, username="mail_pass_test", auth_key="k" * 44):
+ await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@test.local", "auth_key": auth_key})
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": auth_key})
+ return r.json()["access_token"]
+
+
+@pytest.mark.asyncio
+async def test_email_change_without_passphrase_is_refused(client):
+ tok = await _account(client)
+ r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"},
+ json={"email": "attacker@evil.invalid"})
+ assert r.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_email_change_with_wrong_passphrase_is_refused(client):
+ tok = await _account(client)
+ r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"},
+ json={"email": "attacker@evil.invalid", "auth_key": "z" * 44})
+ assert r.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_email_change_with_correct_passphrase_proceeds(client):
+ tok = await _account(client, username="mail_ok_test", auth_key="k" * 44)
+ r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"},
+ json={"email": "new@real.invalid", "auth_key": "k" * 44})
+ assert r.status_code == 200
+ assert r.json().get("email_verification_required") is True
+
+
+@pytest.mark.asyncio
+async def test_profile_patch_without_email_needs_no_passphrase(client):
+ """Regression: a PATCH that does not change the address is unaffected."""
+ tok = await _account(client, username="mail_noop_test")
+ r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"},
+ json={})
+ assert r.status_code == 200
diff --git a/packages/meshbay-hub/tests/test_files_drop_upload.py b/packages/meshbay-hub/tests/test_files_drop_upload.py
index fc15c47..aa39f30 100644
--- a/packages/meshbay-hub/tests/test_files_drop_upload.py
+++ b/packages/meshbay-hub/tests/test_files_drop_upload.py
@@ -41,7 +41,7 @@ def source():
def _run(tmp_path, source, expr):
script = tmp_path / "case.js"
script.write_text(f"{source}\nconsole.log(JSON.stringify({expr}));", encoding="utf-8")
- out = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True)
+ out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True)
return json.loads(out.stdout)
diff --git a/packages/meshbay-hub/tests/test_files_sorting.py b/packages/meshbay-hub/tests/test_files_sorting.py
index 730d1e8..f3aaf34 100644
--- a/packages/meshbay-hub/tests/test_files_sorting.py
+++ b/packages/meshbay-hub/tests/test_files_sorting.py
@@ -38,7 +38,7 @@ def _names(tmp_path, source, rows, key, asc):
f"{source}\nconsole.log(JSON.stringify("
f"sortRows({json.dumps(rows)}, {json.dumps(key)}, {json.dumps(asc)}).map((r) => r.name)));",
encoding="utf-8")
- out = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True)
+ out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True)
return json.loads(out.stdout)
diff --git a/packages/meshbay-hub/tests/test_group_purge.py b/packages/meshbay-hub/tests/test_group_purge.py
index 9c6a664..40d2d54 100644
--- a/packages/meshbay-hub/tests/test_group_purge.py
+++ b/packages/meshbay-hub/tests/test_group_purge.py
@@ -84,7 +84,7 @@ async def _group_with_everything(client, db, owner_token: str, member: str, name
ip_address="192.0.2.1"))
owner_id = (await db.execute(select(Group.admin_id).where(Group.id == gid))).scalar_one()
db.add(GroupInviteLink(group_id=gid, created_by=owner_id, ticket_hash=gid[:8] * 8,
- email_hash="1" * 64, email_masked="m***@e***.com",
+ email_masked="m***@e***.com",
expires_at=datetime.now(UTC) + timedelta(days=1),
redeemed_by=member_id, redeemed_at=datetime.now(UTC)))
await db.commit()
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index d5ffcfe..0172127 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -67,7 +67,7 @@ DEPS = re.compile(r"^ \}, \[([^\]]*)\]\);", re.M)
@pytest.fixture(scope="module")
def app():
- return APP.read_text()
+ return APP.read_text(encoding="utf-8")
def _components(src: str):
@@ -84,7 +84,7 @@ def _all_components():
path = STATIC / name
if not path.exists():
continue
- for cname, body in _components(path.read_text()):
+ for cname, body in _components(path.read_text(encoding="utf-8")):
yield f"{name}:{cname}", body
@@ -147,7 +147,7 @@ def test_the_mse_harness_reads_functions_in_source_order():
running it, which is the one class of defect it would otherwise be well
placed to catch.
"""
- harness = (Path(__file__).parent / "harness" / "mse_harness.mjs").read_text()
+ harness = (Path(__file__).parent / "harness" / "mse_harness.mjs").read_text(encoding="utf-8")
assert "sort" in harness and "indexOf" in harness, (
"the harness still extracts the player functions in a hardcoded order, "
"so it cannot see one declared before its own dependency")
diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py
index 2afd27b..162b074 100644
--- a/packages/meshbay-hub/tests/test_hub_api.py
+++ b/packages/meshbay-hub/tests/test_hub_api.py
@@ -3,6 +3,7 @@ Integration tests for the Hub API.
Uses SQLite in-memory + httpx.AsyncClient — no PostgreSQL, no network.
"""
+from meshbay_common.tokens import HUB_API_AUD
from datetime import UTC
import pytest
@@ -142,7 +143,7 @@ async def test_jwt_offline_verify(client, hub_key_path):
r_pk = await client.get("/v1/hub/pubkey")
hub_pk_pem = r_pk.json()["pk_hub_pem"].encode()
- decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"])
+ decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"], audience=HUB_API_AUD)
assert "jti" in decoded # mandatory
# The token carries no user key. It used to, and the node recorded it as the
# uploader's identity — so whoever issued tokens decided who could delete a
@@ -339,7 +340,7 @@ async def test_jwt_contains_groups_claim(client):
token_pre = r.json()["access_token"]
r_pk = await client.get("/v1/hub/pubkey")
hub_pk = r_pk.json()["pk_hub_pem"].encode()
- decoded_pre = pyjwt.decode(token_pre, hub_pk, algorithms=["EdDSA"])
+ decoded_pre = pyjwt.decode(token_pre, hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD)
assert decoded_pre["groups"] == []
# Alice creates a group and adds Bob
@@ -359,13 +360,13 @@ async def test_jwt_contains_groups_claim(client):
r = await client.post("/v1/users/login", json={
"username": "grp_bob_test", "password": "bobpass99"})
token_post = r.json()["access_token"]
- decoded_post = pyjwt.decode(token_post, hub_pk, algorithms=["EdDSA"])
+ decoded_post = pyjwt.decode(token_post, hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD)
assert group_id in decoded_post["groups"]
# Alice (admin) should also have the group in her JWT
r = await client.post("/v1/users/login", json={
"username": "grp_alice", "password": "alicepass99"})
- decoded_alice = pyjwt.decode(r.json()["access_token"], hub_pk, algorithms=["EdDSA"])
+ decoded_alice = pyjwt.decode(r.json()["access_token"], hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD)
assert group_id in decoded_alice["groups"]
diff --git a/packages/meshbay-hub/tests/test_incoming_membership.py b/packages/meshbay-hub/tests/test_incoming_membership.py
new file mode 100644
index 0000000..708e327
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_incoming_membership.py
@@ -0,0 +1,76 @@
+"""The NAT-punch signal is not a liveness oracle, and only a member reaches it.
+
+`POST /v1/nodes/{id}/incoming` used to check nothing but the caller's own
+address, then reveal whether the node was connected (404 vs 504) and, with QUIC
+on, make it punch. Any authenticated account could poll it for a node's liveness
+and make a stranger's node emit a UDP probe. It now requires a shared active
+group with the node first — the same gate the offer relay uses — checked before
+anything depends on the node's connection state, so a non-member gets one uniform
+403 whether the node is connected or not.
+"""
+
+import pytest
+from test_availability_between_members import (
+ _add_member,
+ _announce_node,
+ _make_group,
+ _make_user,
+)
+
+
+def _incoming(client, node_id, user, peer_ip="1.2.3.4", peer_port=5000):
+ return client.post(f"/v1/nodes/{node_id}/incoming",
+ json={"peer_ip": peer_ip, "peer_port": peer_port},
+ headers={"Authorization": f"Bearer {user['token']}"})
+
+
+@pytest.mark.asyncio
+async def test_a_non_member_is_refused_whether_the_node_is_connected_or_not(client):
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "inc_owner")
+ stranger = await _make_user(client, "inc_stranger")
+ group_id = await _make_group(client, owner, "inc-group")
+ node_id = await _announce_node(client, owner)
+
+ # Node NOT in the connected registry — the stranger gets the membership 403
+ # (not the connection 404), so the answer says nothing about whether the node
+ # is up. The detail is what distinguishes it from the peer_ip refusal that a
+ # request without the gate would give.
+ r_off = await _incoming(client, node_id, stranger)
+ assert r_off.status_code == 403
+ assert "member" in r_off.json()["detail"]
+
+ # Node connected and serving the group — the stranger, not a member, still gets
+ # the membership 403, and never reaches the punch or the connection-state answer.
+ rev._connected_nodes[node_id] = object()
+ rev._node_groups[node_id] = [group_id]
+ try:
+ r_on = await _incoming(client, node_id, stranger)
+ assert r_on.status_code == 403
+ assert "member" in r_on.json()["detail"]
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+ rev._node_groups.pop(node_id, None)
+
+
+@pytest.mark.asyncio
+async def test_a_member_passes_the_membership_gate(client):
+ """A member is not turned away by the gate. (It then reaches the connection
+ check — 404 here, since no real node socket is registered — never 403.)"""
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "inc2_owner")
+ member = await _make_user(client, "inc2_member")
+ group_id = await _make_group(client, owner, "inc2-group")
+ await _add_member(client, owner, group_id, member)
+ node_id = await _announce_node(client, owner)
+
+ rev._node_groups[node_id] = [group_id] # registered/hosted, but no live socket
+ try:
+ r = await _incoming(client, node_id, member)
+ # Past the membership gate: the refusal, if any, is about the connection
+ # or the peer address, never "not a member of any group on this node".
+ assert r.status_code != 403 or "member" not in r.json().get("detail", "")
+ finally:
+ rev._node_groups.pop(node_id, None)
diff --git a/packages/meshbay-hub/tests/test_index_seal_client.py b/packages/meshbay-hub/tests/test_index_seal_client.py
index 20f89d1..f12d791 100644
--- a/packages/meshbay-hub/tests/test_index_seal_client.py
+++ b/packages/meshbay-hub/tests/test_index_seal_client.py
@@ -69,10 +69,10 @@ def _run(frames: list[str], gek: bytes = GEK) -> dict:
with tempfile.TemporaryDirectory() as tmp:
vectors = Path(tmp) / "vectors.json"
vectors.write_text(json.dumps(
- {"gek": gek.hex(), "group_id": GROUP, "frames": frames}))
+ {"gek": gek.hex(), "group_id": GROUP, "frames": frames}), encoding="utf-8")
proc = subprocess.run(
["node", str(PROBE), str(STATIC), str(vectors), transport_argv()],
- capture_output=True, text=True, timeout=120)
+ capture_output=True, text=True, encoding="utf-8", timeout=120)
if proc.returncode != 0:
pytest.fail(f"probe failed:\n{proc.stderr}")
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_indexing_dock.py b/packages/meshbay-hub/tests/test_indexing_dock.py
index d69564d..93803a0 100644
--- a/packages/meshbay-hub/tests/test_indexing_dock.py
+++ b/packages/meshbay-hub/tests/test_indexing_dock.py
@@ -33,11 +33,11 @@ needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node is no
def _run(tmp_path, body: str):
- (tmp_path / "package.json").write_text('{"type":"module"}')
- (tmp_path / "model.js").write_text(MODEL.read_text(encoding="utf-8"))
+ (tmp_path / "package.json").write_text('{"type":"module"}', encoding="utf-8")
+ (tmp_path / "model.js").write_text(MODEL.read_text(encoding="utf-8"), encoding="utf-8")
script = tmp_path / "case.js"
- script.write_text("import * as m from './model.js';\n" + body)
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True,
+ script.write_text("import * as m from './model.js';\n" + body, encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8",
cwd=str(tmp_path))
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_invite_email_choice.py b/packages/meshbay-hub/tests/test_invite_email_choice.py
index e04c31f..de9410d 100644
--- a/packages/meshbay-hub/tests/test_invite_email_choice.py
+++ b/packages/meshbay-hub/tests/test_invite_email_choice.py
@@ -3,8 +3,9 @@ Whether the hub mails an invitation is the inviter's choice, and it is remembere
Mailing it hands the hub the code — `invite-notify` writes it into the message —
which is exactly what §3.4 says the code is for not doing. So the Members tab
-offers it as a box, checked by default, and an unchecked box must mean the hub
-is never asked. The choice lives in an account preference; a key the hub does
+offers it as a box, unchecked by default (mailing the code is opt-in), and an
+unchecked box must mean the hub is never asked. The choice lives in an account
+preference, remembered once set; a key the hub does
not list is refused, and the box would snap back on every click with nothing on
screen to say why.
"""
diff --git a/packages/meshbay-hub/tests/test_invite_link_client.py b/packages/meshbay-hub/tests/test_invite_link_client.py
index 2223ad8..aa8c194 100644
--- a/packages/meshbay-hub/tests/test_invite_link_client.py
+++ b/packages/meshbay-hub/tests/test_invite_link_client.py
@@ -54,8 +54,8 @@ def _module_body() -> str:
def _run(tmp_path, script: str):
harness = tmp_path / "h.js"
- harness.write_text(script)
- out = subprocess.run(["node", str(harness)], capture_output=True, text=True, timeout=60)
+ harness.write_text(script, encoding="utf-8")
+ out = subprocess.run(["node", str(harness)], capture_output=True, encoding="utf-8", timeout=60)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
@@ -188,7 +188,9 @@ def test_the_members_tab_sends_the_code_only_for_the_mail():
sends = [m.start() for m in re.finditer(r"code: node\.code", settings)]
assert len(sends) == 1
before = settings[settings.rfind("\n", 0, sends[0] - 200):sends[0]]
- assert "inviteByEmail ?" in before, "the code reaches the hub only when the box asks"
+ assert "mailIt ?" in before, "the code reaches the hub only when the box asks"
+ assert "const mailIt = inviteByEmail && Boolean(email);" in settings, (
+ "and the box asks only when there is an address to mail")
def test_signing_out_forgets_the_invitation():
diff --git a/packages/meshbay-hub/tests/test_invite_links.py b/packages/meshbay-hub/tests/test_invite_links.py
index 461cf8a..2217cfe 100644
--- a/packages/meshbay-hub/tests/test_invite_links.py
+++ b/packages/meshbay-hub/tests/test_invite_links.py
@@ -63,22 +63,16 @@ def sent(monkeypatch):
# ── Who gets in ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
-async def test_the_addressed_account_joins_and_nobody_else(client, db_session):
+async def test_whoever_opens_it_first_joins_and_nobody_after(client, db_session):
+ # A link travels by any messaging app, so the address the owner typed binds
+ # nothing: an account registered with another one redeems it.
owner = await _account(client, "link_owner")
gid = await _group(client, owner)
r = await _link(client, owner, gid, email="Invitee@Example.test")
assert r.status_code == 201, r.text
ticket = r.json()["ticket"]
- mallory = await _account(client, "link_mallory")
- for route in ("preview", "redeem"):
- r = await client.post(f"/v1/invite-links/{route}", json={"ticket": ticket},
- headers=mallory["h"])
- assert r.status_code == 403 and r.json()["detail"] == "invite_other_account"
- assert "invitee" not in r.text.lower(), "the refusal must not name the address"
-
- # Registered with the address the owner typed, case aside.
- invitee = await _account(client, "link_invitee", email="invitee@example.test")
+ invitee = await _account(client, "link_invitee", email="elsewhere@example.test")
r = await client.post("/v1/invite-links/preview", json={"ticket": ticket},
headers=invitee["h"])
assert r.status_code == 200, r.text
@@ -102,11 +96,30 @@ async def test_the_addressed_account_joins_and_nobody_else(client, db_session):
GroupMember.group_id == gid))).scalars().all()
assert len(rows) == 2
- # And the one who comes after, even with the right address, gets nothing:
- # a twin account cannot exist (addresses are unique), so try the other.
- r = await client.post("/v1/invite-links/redeem", json={"ticket": ticket},
- headers=mallory["h"])
- assert r.status_code == 404
+ # Whoever comes after, even with the address the owner typed, gets nothing.
+ late = await _account(client, "link_late", email="invitee@example.test")
+ for route in ("preview", "redeem"):
+ r = await client.post(f"/v1/invite-links/{route}", json={"ticket": ticket},
+ headers=late["h"])
+ assert r.status_code == 404 and r.json()["detail"] == "invite_not_valid"
+
+
+@pytest.mark.asyncio
+async def test_a_link_needs_no_address_unless_it_is_mailed(client, db_session, sent):
+ owner = await _account(client, "noaddr_owner")
+ gid = await _group(client, owner)
+ r = await _link(client, owner, gid, email="")
+ assert r.status_code == 201, r.text
+ assert r.json()["email_status"] == "not_requested"
+ row = (await db_session.execute(select(GroupInviteLink))).scalar_one()
+ assert row.email_masked is None
+ listed = (await client.get(f"/v1/groups/{gid}/invite-links",
+ headers=owner["h"])).json()["links"]
+ assert [link["email"] for link in listed] == [""]
+
+ r = await _link(client, owner, gid, email="", send_email=True,
+ node_pk=NODE_PK, code=CODE)
+ assert r.status_code == 422 and sent == []
@pytest.mark.asyncio
@@ -115,7 +128,7 @@ async def test_a_ticket_is_stored_only_as_a_hash(client, db_session):
gid = await _group(client, owner)
ticket = (await _link(client, owner, gid)).json()["ticket"]
row = (await db_session.execute(select(GroupInviteLink))).scalar_one()
- assert ticket not in (row.ticket_hash, row.email_masked, row.email_hash)
+ assert ticket not in (row.ticket_hash, row.email_masked)
assert row.ticket_hash == invite_links.ticket_hash(ticket)
assert "invitee@" not in row.email_masked
diff --git a/packages/meshbay-hub/tests/test_layout_measured.py b/packages/meshbay-hub/tests/test_layout_measured.py
index 9bf1bde..c5d6280 100644
--- a/packages/meshbay-hub/tests/test_layout_measured.py
+++ b/packages/meshbay-hub/tests/test_layout_measured.py
@@ -78,7 +78,7 @@ def measured(tmp_path_factory):
"""One browser for every width, because launching one apiece cost the
suite three minutes."""
fragment = tmp_path_factory.mktemp("layout") / "fragment.html"
- fragment.write_text(NAV)
+ fragment.write_text(NAV, encoding="utf-8")
proc = subprocess.run(
["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
str(fragment), *SELECTORS],
@@ -211,7 +211,7 @@ GROUPED_SELECTORS = [".transfer-panel", ".transfer-head", ".transfer-head-summar
@pytest.fixture(scope="module")
def grouped(tmp_path_factory):
fragment = tmp_path_factory.mktemp("grouped") / "fragment.html"
- fragment.write_text(GROUPED)
+ fragment.write_text(GROUPED, encoding="utf-8")
proc = subprocess.run(
["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
str(fragment), *GROUPED_SELECTORS],
diff --git a/packages/meshbay-hub/tests/test_layout_responsive.py b/packages/meshbay-hub/tests/test_layout_responsive.py
index 4ee07d0..bb73557 100644
--- a/packages/meshbay-hub/tests/test_layout_responsive.py
+++ b/packages/meshbay-hub/tests/test_layout_responsive.py
@@ -38,7 +38,7 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture(scope="module")
def css():
- return CSS.read_text()
+ return CSS.read_text(encoding="utf-8")
def _rule(css: str, selector: str) -> str:
@@ -119,7 +119,7 @@ APP = STATIC / "chat-app.js"
@pytest.fixture(scope="module")
def app():
- return APP.read_text()
+ return APP.read_text(encoding="utf-8")
def test_the_chat_panel_is_measured_not_guessed(app):
diff --git a/packages/meshbay-hub/tests/test_lazy_load_failure.py b/packages/meshbay-hub/tests/test_lazy_load_failure.py
new file mode 100644
index 0000000..00c2030
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_lazy_load_failure.py
@@ -0,0 +1,60 @@
+"""
+A view whose module cannot be fetched says so, instead of spinning for good.
+
+Found live after a hub deploy: the hub serves the module graph under
+`/a/<hash>/` for the current hash only, so a tab opened before the deploy asked
+for Search, Videos, Music, Photos and the player under a prefix that now
+answers 404. `lazy.js` swallowed the rejection and kept the placeholder, so
+every one of them showed a spinner for ever, with an empty console, while
+Files and Chat — loaded before the deploy — worked. A reload fixed it, and
+nothing on screen said so.
+
+Measured in a browser: a rejected dynamic import is the one thing no source
+reading can produce.
+"""
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "lazy_failure_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "lazy.js").exists(),
+ reason="Chrome or the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def probe():
+ run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=120)
+ assert run.returncode == 0, run.stderr.decode()[-2000:]
+ out = json.loads(run.stdout.decode())
+ assert "error" not in out, out["error"]
+ return out
+
+
+@pytest.mark.parametrize("view", ["plain", "framed"])
+def test_a_module_that_answers_404_is_not_a_spinner(probe, view):
+ assert not probe[view]["spinner"], "still spinning over a module that will never come"
+ assert probe[view]["notice"], "nothing on screen says the view failed to load"
+ assert probe[view]["buttons"], "no way offered to reload"
+
+
+def test_the_failure_reaches_the_console(probe):
+ """An empty console is what made this cost a scare instead of a glance."""
+ assert len(probe["errors"]) == 2
+ assert all("could not load" in e for e in probe["errors"])
+
+
+def test_an_overlay_fails_inside_its_overlay_and_can_be_closed(probe):
+ assert probe["framed"]["overlay"], "the player's notice landed outside its overlay"
+ assert len(probe["framed"]["buttons"]) == 2
+ assert probe["closed"] == 1, "the Close button did not reach the caller's onClose"
+ assert len(probe["plain"]["buttons"]) == 1, "no onClose, so no Close button"
+
+
+def test_a_module_that_exists_still_renders(probe):
+ assert probe["fine"]
diff --git a/packages/meshbay-hub/tests/test_locales.py b/packages/meshbay-hub/tests/test_locales.py
index 602d161..05e1115 100644
--- a/packages/meshbay-hub/tests/test_locales.py
+++ b/packages/meshbay-hub/tests/test_locales.py
@@ -35,19 +35,20 @@ EXPECTED = ["en", "fr", "es", "pt-BR", "zh-CN", "ja", "de", "it", "nl", "pl"]
def _sandbox(tmp_path):
"""A directory node will treat as ESM, holding the real sources."""
- (tmp_path / "package.json").write_text('{"type":"module"}')
+ (tmp_path / "package.json").write_text('{"type":"module"}', encoding="utf-8")
(tmp_path / "locales").mkdir(exist_ok=True)
for src in LOCALES.glob("*.js"):
- (tmp_path / "locales" / src.name).write_text(src.read_text())
- (tmp_path / "i18n.js").write_text(I18N.read_text())
+ (tmp_path / "locales" / src.name).write_text(src.read_text(encoding="utf-8"),
+ encoding="utf-8")
+ (tmp_path / "i18n.js").write_text(I18N.read_text(encoding="utf-8"), encoding="utf-8")
return tmp_path
def _node(tmp_path, body):
script = tmp_path / "case.js"
- script.write_text(body)
+ script.write_text(body, encoding="utf-8")
proc = subprocess.run(
- ["node", str(script)], capture_output=True, text=True, cwd=str(tmp_path))
+ ["node", str(script)], capture_output=True, text=True, encoding="utf-8", cwd=str(tmp_path))
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py
index 157dbd5..040ff43 100644
--- a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py
+++ b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py
@@ -314,6 +314,9 @@ def test_a_refusal_never_names_the_address():
# ── The doors, driven through the API ────────────────────────────────────────
+_AK = base64.b64encode(b"k" * 32).decode() # the passphrase-derived auth_key these accounts use
+
+
async def _register(client, username: str, email: str, captcha=None):
sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate()
return await client.post("/v1/users/register", json={
@@ -383,11 +386,11 @@ async def test_an_account_may_point_the_hub_at_one_stranger_then_wait(
before = len(wire)
r = await client.patch("/v1/users/me", headers=headers,
- json={"email": "a-stranger@example.test"})
+ json={"auth_key": _AK, "email": "a-stranger@example.test"})
assert r.status_code == 200, r.text
r = await client.patch("/v1/users/me", headers=headers,
- json={"email": "another-stranger@example.test"})
+ json={"auth_key": _AK, "email": "another-stranger@example.test"})
assert r.status_code == 429, r.text
assert len(wire) - before == 1, "the hub mailed a second stranger on demand"
@@ -408,7 +411,7 @@ async def test_the_address_already_pending_may_be_asked_for_again(
"relay_d@example.test")
typo = "jean@gmial.test"
- r = await client.patch("/v1/users/me", headers=headers, json={"email": typo})
+ r = await client.patch("/v1/users/me", headers=headers, json={"auth_key": _AK, "email": typo})
assert r.status_code == 200, r.text
# The recipient's own cooldown is not what is under test here.
@@ -419,7 +422,7 @@ async def test_the_address_already_pending_may_be_asked_for_again(
await db_session.commit()
before = len(wire)
- r = await client.patch("/v1/users/me", headers=headers, json={"email": typo})
+ r = await client.patch("/v1/users/me", headers=headers, json={"auth_key": _AK, "email": typo})
assert r.status_code == 200, (
"a typo locked the account out of correcting it: " + r.text)
assert len(wire) - before == 1
@@ -437,7 +440,7 @@ async def test_the_delay_survives_the_verification_row_being_deleted(
"relay_c@example.test")
r = await client.patch("/v1/users/me", headers=headers,
- json={"email": "c-first@example.test"})
+ json={"auth_key": _AK, "email": "c-first@example.test"})
assert r.status_code == 200, r.text
from meshbay_hub.db.models import EmailVerification
@@ -446,7 +449,7 @@ async def test_the_delay_survives_the_verification_row_being_deleted(
await db_session.commit()
r = await client.patch("/v1/users/me", headers=headers,
- json={"email": "c-second@example.test"})
+ json={"auth_key": _AK, "email": "c-second@example.test"})
assert r.status_code == 429, (
"the delay was counted from a table the handler empties")
diff --git a/packages/meshbay-hub/tests/test_media_pager.py b/packages/meshbay-hub/tests/test_media_pager.py
index a8a0569..835ed6f 100644
--- a/packages/meshbay-hub/tests/test_media_pager.py
+++ b/packages/meshbay-hub/tests/test_media_pager.py
@@ -36,8 +36,8 @@ def source():
def _run(tmp_path, source, expr):
script = tmp_path / "case.js"
- script.write_text(f"{source}\nconsole.log(JSON.stringify({expr}));")
- out = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True)
+ script.write_text(f"{source}\nconsole.log(JSON.stringify({expr}));", encoding="utf-8")
+ out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True)
return json.loads(out.stdout)
diff --git a/packages/meshbay-hub/tests/test_member_removal.py b/packages/meshbay-hub/tests/test_member_removal.py
index 7af73a0..5073873 100644
--- a/packages/meshbay-hub/tests/test_member_removal.py
+++ b/packages/meshbay-hub/tests/test_member_removal.py
@@ -27,7 +27,7 @@ pytestmark = pytest.mark.skipif(
def _remove_member_body() -> str:
- source = SETTINGS.read_text()
+ source = SETTINGS.read_text(encoding="utf-8")
start = source.find("const removeMember = useCallback(")
assert start != -1, "removeMember is gone from group-settings.js"
end = source.find("\n }, [", start)
diff --git a/packages/meshbay-hub/tests/test_memory_ceiling.py b/packages/meshbay-hub/tests/test_memory_ceiling.py
index 5984292..9ea6ad3 100644
--- a/packages/meshbay-hub/tests/test_memory_ceiling.py
+++ b/packages/meshbay-hub/tests/test_memory_ceiling.py
@@ -51,7 +51,7 @@ def _lift(name, source):
@pytest.fixture(scope="module")
def target_fn():
"""The ceiling, its error and the real function — read, never re-typed."""
- src = FILE_UTILS.read_text()
+ src = FILE_UTILS.read_text(encoding="utf-8")
ceiling = re.search(r"^const MEMORY_CEILING = .*?;$", src, re.M)
assert ceiling, "MEMORY_CEILING is gone from file-utils.js"
# The test's own CEILING constant must agree with the source's, or every
@@ -123,8 +123,8 @@ try {{
outcome = {{ kind: 'refused', name: err.name, message: err.message }};
}}
console.log(JSON.stringify(outcome));
-""")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@@ -259,7 +259,7 @@ def test_no_unguarded_memory_floor(target_fn):
def test_the_guard_is_what_the_preview_uses_too(target_fn):
"""`FilePreview` decrypts a whole entry with no writable at all, so it needs
the same ceiling — and must import it rather than keep a second number."""
- files_app = (STATIC / "files-app.js").read_text()
+ files_app = (STATIC / "files-app.js").read_text(encoding="utf-8")
assert "MEMORY_CEILING" in files_app
assert re.search(r"entry\.size\s*>\s*MEMORY_CEILING", files_app), (
"the preview modal must refuse an oversized entry before fetching it")
diff --git a/packages/meshbay-hub/tests/test_mnp_token.py b/packages/meshbay-hub/tests/test_mnp_token.py
new file mode 100644
index 0000000..3aa3115
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_mnp_token.py
@@ -0,0 +1,92 @@
+"""The MNP token: a member's credential to a node, useless at the hub API.
+
+A member hands whatever token it presents to every node it connects to (the MNP
+handshake). That must not be the hub session token, which opens the hub API —
+otherwise a node operator holds a live credential for the member. `POST
+/v1/nodes/mnp-token` mints a short-lived, node-audience token for that purpose;
+these tests pin that it authorises to a node and is refused by the hub API.
+"""
+
+import pytest
+
+from meshbay_common.handshake import HandshakeError, authorize_token
+from meshbay_common.tokens import MNP_AUD
+
+
+async def _session_token(client, username="mnp_user_test"):
+ await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@test.local", "auth_key": "k" * 44})
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": "k" * 44})
+ return r.json()["access_token"]
+
+
+@pytest.mark.asyncio
+async def test_mnp_token_endpoint_needs_a_session(client):
+ # No Authorization header at all — FastAPI rejects the required header (422),
+ # like every other authenticated route; the point is it is not minted anonymously.
+ r = await client.post("/v1/nodes/mnp-token")
+ assert r.status_code in (401, 403, 422)
+
+
+@pytest.mark.asyncio
+async def test_mnp_token_is_minted_for_a_member(client):
+ tok = await _session_token(client)
+ r = await client.post("/v1/nodes/mnp-token",
+ headers={"Authorization": f"Bearer {tok}"})
+ assert r.status_code == 200
+ assert r.json().get("mnp_token")
+
+
+@pytest.mark.asyncio
+async def test_mnp_token_is_refused_at_the_hub_api(client):
+ """The whole point: the credential a node receives opens nothing at the hub."""
+ tok = await _session_token(client, "mnp_api_test")
+ mnp = (await client.post("/v1/nodes/mnp-token",
+ headers={"Authorization": f"Bearer {tok}"})).json()["mnp_token"]
+ # Presenting it to a hub endpoint fails.
+ r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {mnp}"})
+ assert r.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_a_session_token_is_refused_by_a_node_but_the_mnp_token_is_not(client):
+ """The mirror image, at the node's decode: the session token (what the API
+ accepts) is refused by `authorize_token`, and the MNP token is accepted."""
+ from meshbay_hub.auth import hub_public_key_pem
+
+ # Make the member a member of a group so the MNP token carries it.
+ tok = await _session_token(client, "mnp_node_test")
+ H = {"Authorization": f"Bearer {tok}"}
+ gid = (await client.post("/v1/groups", headers=H, json={
+ "name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"]
+ mnp = (await client.post("/v1/nodes/mnp-token", headers=H)).json()["mnp_token"]
+ pk = hub_public_key_pem()
+
+ # The session token is refused by the node handshake (wrong audience).
+ with pytest.raises(HandshakeError):
+ authorize_token(tok, pk, group_id=gid)
+ # The MNP token authorises the member to the node.
+ peer = authorize_token(mnp, pk, group_id=gid)
+ assert peer.group_id == gid
+
+
+@pytest.mark.asyncio
+async def test_the_mnp_token_is_bound_to_the_node_it_names(client):
+ """E10: a token minted for node A is refused by node B, so an operator who
+ captures a member's token cannot replay it to another of the member's nodes."""
+ from meshbay_hub.auth import hub_public_key_pem
+
+ tok = await _session_token(client, "mnp_bind_test")
+ H = {"Authorization": f"Bearer {tok}"}
+ gid = (await client.post("/v1/groups", headers=H, json={
+ "name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"]
+ # A token bound to node A's key.
+ mnp = (await client.post("/v1/nodes/mnp-token", headers=H,
+ json={"node_pk": "node-A-pk"})).json()["mnp_token"]
+ pk = hub_public_key_pem()
+ # Node B refuses it; node A accepts it.
+ with pytest.raises(HandshakeError, match="this node"):
+ authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-B-pk")
+ peer = authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-A-pk")
+ assert peer.group_id == gid
diff --git a/packages/meshbay-hub/tests/test_music_queue.py b/packages/meshbay-hub/tests/test_music_queue.py
index 5bf9e97..8f3f9bd 100644
--- a/packages/meshbay-hub/tests/test_music_queue.py
+++ b/packages/meshbay-hub/tests/test_music_queue.py
@@ -123,7 +123,7 @@ def test_an_unreachable_group_is_skipped_whole_rather_than_one_track_at_a_time(s
def test_the_music_wrapper_names_the_op_it_forwards(name):
"""A wrapper that takes two arguments forwards two, and the third is lost
without a word. Both of these did exactly that."""
- src = (STATIC / name).read_text()
+ src = (STATIC / name).read_text(encoding="utf-8")
marker = ("const onPlayQueue = useCallback((tracks, startIndex, op)"
if name == "group-page.js"
else "const handleMusicPlay = useCallback((tracks, startIndex, op)")
diff --git a/packages/meshbay-hub/tests/test_node_page_pairing.py b/packages/meshbay-hub/tests/test_node_page_pairing.py
new file mode 100644
index 0000000..435488e
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_node_page_pairing.py
@@ -0,0 +1,48 @@
+"""
+The Node page's "No operator paired" banner.
+
+A node publishes its roster only once it has signed in to the hub, and until
+then it has no way to know who is paired. It used to answer `false` in that
+window and the page took `!operator_paired` for "not paired" -- so a node stuck
+waiting for its account told its operator to pair again, about a pairing that
+was intact, and sent them after the wrong problem.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+NODE_PAGE = STATIC / "node-page.js"
+
+
+def _source() -> str:
+ return NODE_PAGE.read_text(encoding="utf-8")
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="node is not available")
+def test_paired_is_unknown_unless_the_node_says_true_or_false(tmp_path):
+ m = re.search(r"^export function pairedFrom\(.*?^\}", _source(), re.M | re.S)
+ assert m, "node-page.js no longer has pairedFrom"
+ script = tmp_path / "case.js"
+ cases = [{"operator_paired": True}, {"operator_paired": False},
+ {"operator_paired": None}, {}, None, {"operator_paired": "yes"}]
+ script.write_text(m.group(0).replace("export ", "")
+ + f"\nconsole.log(JSON.stringify({json.dumps(cases)}.map(pairedFrom)));",
+ encoding="utf-8")
+ out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True)
+ assert json.loads(out.stdout) == [True, False, None, None, None, None]
+
+
+def test_the_banner_needs_an_explicit_false():
+ src = _source()
+ banner = src.index('class="node-pair-banner"')
+ guard = src.rindex("${", 0, banner)
+ assert src[guard:banner].startswith("${operatorPaired === false &&"), (
+ "the pairing banner must not show for a node that has not read its roster")
+ assert "useState(null)" in src.split("const [operatorPaired", 1)[1].split("\n", 1)[0]
+ assert "setOperatorPaired(!!" not in src
diff --git a/packages/meshbay-hub/tests/test_node_page_width_measured.py b/packages/meshbay-hub/tests/test_node_page_width_measured.py
index bd6a231..ae49641 100644
--- a/packages/meshbay-hub/tests/test_node_page_width_measured.py
+++ b/packages/meshbay-hub/tests/test_node_page_width_measured.py
@@ -122,7 +122,7 @@ SELECTORS = ["#node.node-page", "#settings", "#node .node-table-scroll",
def measured(tmp_path_factory):
"""One browser for every width — launching one apiece cost three minutes."""
fragment = tmp_path_factory.mktemp("nodewidth") / "fragment.html"
- fragment.write_text(FRAGMENT)
+ fragment.write_text(FRAGMENT, encoding="utf-8")
proc = subprocess.run(
["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
str(fragment), *SELECTORS],
diff --git a/packages/meshbay-hub/tests/test_node_scope_not_admin.py b/packages/meshbay-hub/tests/test_node_scope_not_admin.py
new file mode 100644
index 0000000..58cabb1
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_node_scope_not_admin.py
@@ -0,0 +1,159 @@
+"""A node-scoped daemon token must never reach the hub admin/moderator surface.
+
+A node's authority and a hub role are different notions (docs/MESHBAY_DESIGN.md
+§7.1, NS4): what a node may do is decided by its operator's roster pin on the
+node and by the deliberately narrow node scope; being an admin or moderator is a
+hub role on a *person's* account, exercised from a browser with a user-scoped
+token. When the operator's account also holds a hub role — which is the case on
+the reference deployment, where the operator is an admin and runs a node — the
+`scope:"node"` token the daemon keeps in memory used to pass `require_admin` and
+`require_moderator`, because those checked only the role and not the scope. The
+scope gate was wired onto `require_user_scope` (group mutation) alone.
+
+These are refusals: each asserts the node token is turned away with 403, and the
+same account's user token is let through, so the guard is proven to bite on the
+scope and not on the account.
+"""
+
+import base64
+import time
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_hub.api.deps import set_admin_usernames
+
+
+def _gen_ed25519():
+ sk = Ed25519PrivateKey.generate()
+ pk_raw = sk.public_key().public_bytes(
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+ return sk, base64.b64encode(pk_raw).decode()
+
+
+async def _register(client, username):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@test.local",
+ "auth_key": "k" * 44})
+ assert r.status_code == 201
+
+
+async def _user_login(client, username):
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": "k" * 44})
+ assert r.status_code == 200
+ return r.json()["access_token"]
+
+
+async def _node_token(client, username, user_token):
+ """Link a node key for `username` and return a scope:"node" token for it."""
+ sk_node, pk_node = _gen_ed25519()
+ r = await client.put("/v1/users/me/node_key",
+ json={"pk_node_ed25519": pk_node},
+ headers={"Authorization": f"Bearer {user_token}"})
+ assert r.status_code == 200
+ ts = int(time.time())
+ sig = sk_node.sign(f"meshbay:node_auth:{username}:{ts}".encode())
+ r = await client.post("/v1/nodes/auth", json={
+ "username": username, "timestamp": ts,
+ "signature": base64.b64encode(sig).decode()})
+ assert r.status_code == 200
+ data = r.json()
+ # Confirm we really are holding a node-scoped token.
+ import jwt as _jwt
+ assert _jwt.decode(data["access_token"], options={"verify_signature": False}
+ )["scope"] == "node"
+ return data["access_token"]
+
+
+async def _admin_with_node(client, username="op_admin_test"):
+ """An admin account that also runs a node — the reference-deployment case.
+
+ Returns (user_token, node_token) for the same account.
+ """
+ await _register(client, username)
+ set_admin_usernames([username])
+ user_token = await _user_login(client, username)
+ node_token = await _node_token(client, username, user_token)
+ return user_token, node_token
+
+
+def _bearer(token):
+ return {"Authorization": f"Bearer {token}"}
+
+
+# ── require_admin ────────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_node_token_cannot_reach_admin_stats(client):
+ user_token, node_token = await _admin_with_node(client)
+ assert (await client.get("/v1/admin/stats", headers=_bearer(node_token))
+ ).status_code == 403
+ # The same account, from a browser, is admin and gets in.
+ assert (await client.get("/v1/admin/stats", headers=_bearer(user_token))
+ ).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_node_token_cannot_revoke(client):
+ """The sharpest one: revoke is signed and broadcast to every node."""
+ _, node_token = await _admin_with_node(client)
+ r = await client.post("/v1/admin/revoke", headers=_bearer(node_token),
+ json={"target": "group", "target_id": "whatever"})
+ assert r.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_node_token_cannot_change_instance_policy(client):
+ _, node_token = await _admin_with_node(client)
+ r = await client.patch("/v1/admin/settings", headers=_bearer(node_token),
+ json={"allow_public_groups": True})
+ assert r.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_node_token_cannot_delete_account(client):
+ _, node_token = await _admin_with_node(client)
+ r = await client.delete("/v1/admin/users/some-id", headers=_bearer(node_token))
+ assert r.status_code == 403
+
+
+# ── require_moderator (a wider set of accounts, including the IP audit log) ───
+
+@pytest.mark.asyncio
+async def test_node_token_cannot_read_ip_audit_log(client):
+ user_token, node_token = await _admin_with_node(client)
+ assert (await client.get("/v1/admin/logs", headers=_bearer(node_token))
+ ).status_code == 403
+ assert (await client.get("/v1/admin/logs", headers=_bearer(user_token))
+ ).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_node_token_cannot_list_nodes(client):
+ _, node_token = await _admin_with_node(client)
+ assert (await client.get("/v1/admin/nodes", headers=_bearer(node_token))
+ ).status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_a_moderators_node_token_is_also_refused(client):
+ """A moderator (not admin) who runs a node: the moderation surface is still
+ the person's, not the machine's."""
+ # An admin promotes a second account to moderator, which then links a node.
+ admin_user_token, _ = await _admin_with_node(client, "boss_admin_test")
+ await _register(client, "mod_test")
+ mod_user_token = await _user_login(client, "mod_test")
+ # promote
+ import jwt as _jwt
+ mod_id = _jwt.decode(mod_user_token, options={"verify_signature": False})["sub"]
+ r = await client.patch(f"/v1/admin/users/{mod_id}", json={"role": "moderator"},
+ headers=_bearer(admin_user_token))
+ assert r.status_code == 200
+ mod_node_token = await _node_token(client, "mod_test", mod_user_token)
+ # user-scoped moderator token gets in; node-scoped one does not
+ assert (await client.get("/v1/admin/stats", headers=_bearer(mod_user_token))
+ ).status_code == 200
+ assert (await client.get("/v1/admin/stats", headers=_bearer(mod_node_token))
+ ).status_code == 403
diff --git a/packages/meshbay-hub/tests/test_notification_dismissal.py b/packages/meshbay-hub/tests/test_notification_dismissal.py
index d498b4d..0198dee 100644
--- a/packages/meshbay-hub/tests/test_notification_dismissal.py
+++ b/packages/meshbay-hub/tests/test_notification_dismissal.py
@@ -149,7 +149,7 @@ def test_the_spa_asks_for_unread_only():
exercising it, and here it is the only thing that catches the defect that
actually happened.
"""
- src = APP.read_text()
+ src = APP.read_text(encoding="utf-8")
fetches = re.findall(r"hubFetch\('(/v1/notifications\?[^']*)'", src)
assert fetches, "the notification list fetch is no longer where this reads it"
for url in fetches:
@@ -167,7 +167,7 @@ def test_the_feed_does_not_style_a_state_it_can_no_longer_show():
was dead code that described behaviour the application had abandoned —
and reading it is what made the two halves' disagreement visible.
"""
- src = APP.read_text()
+ src = APP.read_text(encoding="utf-8")
assert "n.read ?" not in src, (
"the feed branches on `read` again; either it is dead code or the "
"dismissal contract has changed and this file should say how")
diff --git a/packages/meshbay-hub/tests/test_offer_retry.py b/packages/meshbay-hub/tests/test_offer_retry.py
index 6389f8e..1239ae0 100644
--- a/packages/meshbay-hub/tests/test_offer_retry.py
+++ b/packages/meshbay-hub/tests/test_offer_retry.py
@@ -33,7 +33,7 @@ pytestmark = pytest.mark.skipif(
def _post(**cfg) -> dict:
proc = subprocess.run(
["node", str(HARNESS), transport_argv(), json.dumps(cfg)],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_playlist_crypto.py b/packages/meshbay-hub/tests/test_playlist_crypto.py
index 4ff3080..4dcfcb6 100644
--- a/packages/meshbay-hub/tests/test_playlist_crypto.py
+++ b/packages/meshbay-hub/tests/test_playlist_crypto.py
@@ -69,11 +69,11 @@ const bigBody = (n) => ({
def _run(tmp_path, body):
- src = SRC.read_text().replace("export {", "const _unused_export = {")
+ src = SRC.read_text(encoding="utf-8").replace("export {", "const _unused_export = {")
script = tmp_path / "case.mjs"
- script.write_text(f"{src}\n{PRELUDE}\n{body}\n")
+ script.write_text(f"{src}\n{PRELUDE}\n{body}\n", encoding="utf-8")
out = subprocess.run(["node", str(script)],
- capture_output=True, text=True, timeout=60)
+ capture_output=True, text=True, encoding="utf-8", timeout=60)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
diff --git a/packages/meshbay-hub/tests/test_playlist_key.py b/packages/meshbay-hub/tests/test_playlist_key.py
index dbed8ae..261cc32 100644
--- a/packages/meshbay-hub/tests/test_playlist_key.py
+++ b/packages/meshbay-hub/tests/test_playlist_key.py
@@ -58,11 +58,11 @@ globalThis.argon2 = {
def _run(tmp_path, body):
- src = KEYDERIVE.read_text()
+ src = KEYDERIVE.read_text(encoding="utf-8")
script = tmp_path / "case.mjs"
- script.write_text(f"{PRELUDE}\n{src}\n{body}\n")
+ script.write_text(f"{PRELUDE}\n{src}\n{body}\n", encoding="utf-8")
out = subprocess.run(["node", str(script)],
- capture_output=True, text=True, timeout=60)
+ capture_output=True, text=True, encoding="utf-8", timeout=60)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
diff --git a/packages/meshbay-hub/tests/test_playlist_merge.py b/packages/meshbay-hub/tests/test_playlist_merge.py
index 2c7b612..dd089e8 100644
--- a/packages/meshbay-hub/tests/test_playlist_merge.py
+++ b/packages/meshbay-hub/tests/test_playlist_merge.py
@@ -39,7 +39,7 @@ EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M | re.S)
@pytest.fixture(scope="module")
def module_source():
- text = SRC.read_text()
+ text = SRC.read_text(encoding="utf-8")
assert not IMPORT.search(text), (
"playlist-merge.js has gained an import. It is executed standalone "
"here, and the merge is untested from the moment it cannot be — keep "
@@ -53,9 +53,9 @@ def module_source():
def _run(tmp_path, module_source, body):
script = tmp_path / "case.js"
- script.write_text(f"{module_source}\n{body}\n")
+ script.write_text(f"{module_source}\n{body}\n", encoding="utf-8")
out = subprocess.run(
- ["node", str(script)], capture_output=True, text=True, timeout=30)
+ ["node", str(script)], capture_output=True, text=True, encoding="utf-8", timeout=30)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
diff --git a/packages/meshbay-hub/tests/test_queue_ops.py b/packages/meshbay-hub/tests/test_queue_ops.py
index 64b5ca1..e3853f2 100644
--- a/packages/meshbay-hub/tests/test_queue_ops.py
+++ b/packages/meshbay-hub/tests/test_queue_ops.py
@@ -38,7 +38,7 @@ EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M)
@pytest.fixture(scope="module")
def module_source():
- text = SRC.read_text()
+ text = SRC.read_text(encoding="utf-8")
assert not IMPORT.search(text), (
"queue-ops.js has gained an import. It is executed standalone here, "
"and the queue is untested from the moment it cannot be — keep the "
@@ -52,9 +52,9 @@ def module_source():
def _run(tmp_path, module_source, body):
script = tmp_path / "case.js"
- script.write_text(f"{module_source}\n{body}\n")
+ script.write_text(f"{module_source}\n{body}\n", encoding="utf-8")
out = subprocess.run(
- ["node", str(script)], capture_output=True, text=True, timeout=30)
+ ["node", str(script)], capture_output=True, text=True, encoding="utf-8", timeout=30)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
diff --git a/packages/meshbay-hub/tests/test_rate_limit_key.py b/packages/meshbay-hub/tests/test_rate_limit_key.py
new file mode 100644
index 0000000..679f546
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_rate_limit_key.py
@@ -0,0 +1,48 @@
+"""
+Rate limits are per client, not per proxy.
+
+meshbay.org's hub sits behind Caddy on the same host, so every request's TCP peer
+is loopback. The limiter was keyed on that peer (slowapi's get_remote_address)
+while `client_ip` -- written "for the audit log and rate limiting" -- went
+unused by it, so each limit was one bucket for the whole internet: ten node
+sign-ins a minute shared by every node. A node that started while others signed
+in got 429, sat in `waiting_for_hub`, and the desktop app took that for no node
+at all. Every other test runs with the limiter disabled, which is how it hid.
+"""
+
+import pytest
+from meshbay_hub.api.middleware import limiter
+from meshbay_hub.api.netutil import client_ip
+
+
+@pytest.fixture
+def limits_on():
+ limiter.reset()
+ limiter.enabled = True
+ yield
+ limiter.enabled = False
+ limiter.reset()
+
+
+def _auth(client, ip):
+ # The ASGI test transport's peer is 127.0.0.1 -- a trusted proxy, as Caddy is.
+ return client.post("/v1/nodes/auth",
+ json={"username": "nobody", "timestamp": 0, "signature": "AAAA"},
+ headers={"X-Forwarded-For": ip})
+
+
+async def test_one_client_is_limited(client, limits_on):
+ for _ in range(10):
+ assert (await _auth(client, "203.0.113.1")).status_code != 429
+ assert (await _auth(client, "203.0.113.1")).status_code == 429
+
+
+async def test_another_client_behind_the_same_proxy_is_not(client, limits_on):
+ for _ in range(11):
+ await _auth(client, "203.0.113.1")
+ assert (await _auth(client, "203.0.113.2")).status_code != 429, (
+ "a second client behind the proxy shared the first one's bucket")
+
+
+def test_the_limiter_resolves_clients_the_way_the_audit_log_does():
+ assert limiter._key_func is client_ip
diff --git a/packages/meshbay-hub/tests/test_reconnect_refresh.py b/packages/meshbay-hub/tests/test_reconnect_refresh.py
index ff6d7fb..cd702d3 100644
--- a/packages/meshbay-hub/tests/test_reconnect_refresh.py
+++ b/packages/meshbay-hub/tests/test_reconnect_refresh.py
@@ -43,12 +43,12 @@ def transport():
@pytest.fixture(scope="module")
def group_page():
- return GROUP_PAGE.read_text()
+ return GROUP_PAGE.read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def video_player():
- return VIDEO_PLAYER.read_text()
+ return VIDEO_PLAYER.read_text(encoding="utf-8")
def _reconnect_loop(transport: str) -> str:
@@ -93,7 +93,7 @@ def test_one_listener_throwing_does_not_rob_the_next(transport):
def test_nothing_assigns_the_old_setter():
"""`grep onReconnected =` is what this is, spelled so it cannot rot."""
offenders = [p.name for p in STATIC.glob("*.js")
- if re.search(r"\.onReconnected\s*=", p.read_text())]
+ if re.search(r"\.onReconnected\s*=", p.read_text(encoding="utf-8"))]
assert offenders == [], (
f"{offenders} still assign a slot that no longer exists")
@@ -159,10 +159,10 @@ def test_every_harness_that_renders_the_group_page_stubs_the_subscription():
"""
harness = Path(__file__).parent / "harness"
stubs = [p for p in harness.glob("*.py")
- if "window.MeshBayTransport" in p.read_text()
- and "GroupPage" in p.read_text()]
+ if "window.MeshBayTransport" in p.read_text(encoding="utf-8")
+ and "GroupPage" in p.read_text(encoding="utf-8")]
assert stubs, "no harness stubs the transport any more — has this moved?"
missing = [p.name for p in stubs
- if "addReconnectListener" not in p.read_text()]
+ if "addReconnectListener" not in p.read_text(encoding="utf-8")]
assert missing == [], (
f"{missing} render GroupPage against a node that cannot be subscribed to")
diff --git a/packages/meshbay-hub/tests/test_recovery_key.py b/packages/meshbay-hub/tests/test_recovery_key.py
index 54415f6..e43e6de 100644
--- a/packages/meshbay-hub/tests/test_recovery_key.py
+++ b/packages/meshbay-hub/tests/test_recovery_key.py
@@ -102,10 +102,10 @@ const fp = async (key) => hex(await webcrypto.subtle.encrypt(
def result(tmp_path_factory):
d = tmp_path_factory.mktemp("recovery")
harness = d / "harness.cjs"
- harness.write_text(_HARNESS)
+ harness.write_text(_HARNESS, encoding="utf-8")
proc = subprocess.run(
["node", str(harness), str(KEYDERIVE)],
- capture_output=True, text=True, timeout=120,
+ capture_output=True, text=True, encoding="utf-8", timeout=120,
)
if proc.returncode != 0:
pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}")
diff --git a/packages/meshbay-hub/tests/test_resume_position.py b/packages/meshbay-hub/tests/test_resume_position.py
index 07ac23a..67eb786 100644
--- a/packages/meshbay-hub/tests/test_resume_position.py
+++ b/packages/meshbay-hub/tests/test_resume_position.py
@@ -85,7 +85,7 @@ def _run(body: str, tmp_path: Path):
f"{body}\n"
"console.log(JSON.stringify(out));\n",
encoding="utf-8")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_revoke_is_one_path.py b/packages/meshbay-hub/tests/test_revoke_is_one_path.py
new file mode 100644
index 0000000..2acb46c
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_revoke_is_one_path.py
@@ -0,0 +1,164 @@
+"""Revoke is one door, it is admin-only, and it broadcasts.
+
+Two coupled gaps used to sit in the moderation surface (docs/MESHBAY_DESIGN.md
+§7.5):
+
+ * `admin_patch_group` let a *moderator* set a group to `revoked`, while the
+ user handler makes revoke admin-only;
+ * a `revoked` set through either PATCH was **never broadcast** to nodes —
+ unlike `POST /v1/admin/revoke` and account deletion — so it behaved like
+ `suspended` on nodes while claiming to be the signed, node-enforced state.
+
+Revoke now has one path, `POST /v1/admin/revoke` (admin-only, signs and
+broadcasts). PATCH refuses `revoked` and refuses to move an entity *out* of
+`revoked` unless the caller is an admin.
+"""
+
+import pytest
+
+from meshbay_hub.api.deps import set_admin_usernames
+
+
+async def _register(client, username):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@test.local", "auth_key": "k" * 44})
+ assert r.status_code == 201
+ return r.json()["user_id"]
+
+
+async def _login(client, username):
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": "k" * 44})
+ assert r.status_code == 200
+ return r.json()["access_token"]
+
+
+def _h(token):
+ return {"Authorization": f"Bearer {token}"}
+
+
+async def _admin(client, name="admin_rev_test"):
+ await _register(client, name)
+ set_admin_usernames([name])
+ return await _login(client, name)
+
+
+async def _moderator(client, admin_token, name="mod_rev_test"):
+ uid = await _register(client, name)
+ r = await client.patch(f"/v1/admin/users/{uid}", json={"role": "moderator"},
+ headers=_h(admin_token))
+ assert r.status_code == 200
+ return uid, await _login(client, name)
+
+
+async def _a_group(client, owner="owner_rev_test"):
+ await _register(client, owner)
+ tok = await _login(client, owner)
+ r = await client.post("/v1/groups", headers=_h(tok),
+ json={"name": "g", "visibility": "private", "join_policy": "invite"})
+ assert r.status_code == 201
+ return r.json()["group_id"]
+
+
+async def _group_status(client, admin_token, group_id):
+ data = (await client.get("/v1/admin/groups?limit=200", headers=_h(admin_token))).json()
+ return next(g["status"] for g in data["groups"] if g["id"] == group_id)
+
+
+# ── PATCH cannot revoke ──────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_revoke_a_group_via_patch(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "revoked"},
+ headers=_h(mod_token))
+ assert r.status_code == 403
+ assert await _group_status(client, admin_token, gid) == "active"
+
+
+@pytest.mark.asyncio
+async def test_admin_patch_revoked_group_is_redirected_not_silently_applied(client):
+ admin_token = await _admin(client)
+ gid = await _a_group(client)
+ r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "revoked"},
+ headers=_h(admin_token))
+ assert r.status_code == 400
+ assert "revoke" in r.json()["detail"].lower()
+ # And it was not quietly applied.
+ assert await _group_status(client, admin_token, gid) == "active"
+
+
+@pytest.mark.asyncio
+async def test_admin_patch_revoked_user_is_redirected(client):
+ admin_token = await _admin(client)
+ victim = await _register(client, "vic_rev_test")
+ r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "revoked"},
+ headers=_h(admin_token))
+ assert r.status_code == 400
+
+
+# ── The one door: /v1/admin/revoke, admin-only, and it broadcasts ────────────
+
+@pytest.mark.asyncio
+async def test_admin_revoke_group_broadcasts_and_sets_status(client):
+ admin_token = await _admin(client)
+ gid = await _a_group(client)
+ r = await client.post("/v1/admin/revoke", headers=_h(admin_token),
+ json={"target": "group", "target_id": gid})
+ assert r.status_code == 200
+ body = r.json()
+ assert body["status"] == "revoked"
+ assert "nodes_notified" in body # it went through the broadcast path
+ assert await _group_status(client, admin_token, gid) == "revoked"
+
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_reach_the_revoke_endpoint(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ r = await client.post("/v1/admin/revoke", headers=_h(mod_token),
+ json={"target": "group", "target_id": gid})
+ assert r.status_code == 403
+
+
+# ── Leaving `revoked` is an admin's call ─────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_unrevoke_a_group(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ await client.post("/v1/admin/revoke", headers=_h(admin_token),
+ json={"target": "group", "target_id": gid})
+ r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "active"},
+ headers=_h(mod_token))
+ assert r.status_code == 403
+ assert await _group_status(client, admin_token, gid) == "revoked"
+
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_unrevoke_a_user(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ victim = await _register(client, "vic2_rev_test")
+ await client.post("/v1/admin/revoke", headers=_h(admin_token),
+ json={"target": "user", "target_id": victim})
+ r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "active"},
+ headers=_h(mod_token))
+ assert r.status_code == 403
+
+
+# ── Regression: suspend/unsuspend by a moderator still works ─────────────────
+
+@pytest.mark.asyncio
+async def test_moderator_can_still_suspend_and_restore(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ assert (await client.patch(f"/v1/admin/groups/{gid}", json={"status": "suspended"},
+ headers=_h(mod_token))).status_code == 200
+ assert (await client.patch(f"/v1/admin/groups/{gid}", json={"status": "active"},
+ headers=_h(mod_token))).status_code == 200
diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py
index 7c0f272..b278d0c 100644
--- a/packages/meshbay-hub/tests/test_rewrap_fanout.py
+++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py
@@ -154,10 +154,10 @@ const names = (a) => a.map((x) => x.name).sort();
def result(tmp_path_factory):
d = tmp_path_factory.mktemp("rewrap")
harness = d / "harness.cjs"
- harness.write_text(_HARNESS)
+ harness.write_text(_HARNESS, encoding="utf-8")
proc = subprocess.run(
["node", str(harness), transport_argv()],
- capture_output=True, text=True, timeout=120,
+ capture_output=True, text=True, encoding="utf-8", timeout=120,
)
if proc.returncode != 0:
pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}")
diff --git a/packages/meshbay-hub/tests/test_search_connect_deadline.py b/packages/meshbay-hub/tests/test_search_connect_deadline.py
index 2d3db06..953f027 100644
--- a/packages/meshbay-hub/tests/test_search_connect_deadline.py
+++ b/packages/meshbay-hub/tests/test_search_connect_deadline.py
@@ -49,7 +49,7 @@ CAP_MS = 30000
def _attempt(**cfg) -> dict:
proc = subprocess.run(
["node", str(HARNESS), search_argv(), json.dumps(cfg)],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_search_fanout.py b/packages/meshbay-hub/tests/test_search_fanout.py
index fca879a..23a8b67 100644
--- a/packages/meshbay-hub/tests/test_search_fanout.py
+++ b/packages/meshbay-hub/tests/test_search_fanout.py
@@ -67,7 +67,7 @@ DEAD_MS = 10000 # a node that does not, to the connection deadline
def _sweep(**cfg) -> dict:
proc = subprocess.run(
["node", str(HARNESS), search_argv(), json.dumps(cfg)],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_search_files_unmerged.py b/packages/meshbay-hub/tests/test_search_files_unmerged.py
index b84b25d..b6621e5 100644
--- a/packages/meshbay-hub/tests/test_search_files_unmerged.py
+++ b/packages/meshbay-hub/tests/test_search_files_unmerged.py
@@ -38,7 +38,7 @@ MERGE_CALL = "mergeUnitEntries"
def _memo(name):
"""The body of `const <name> = useMemo(() => { ... }, [...]);`."""
- src = SEARCH_PAGE.read_text()
+ src = SEARCH_PAGE.read_text(encoding="utf-8")
m = re.search(
r"^ const " + re.escape(name) + r" = useMemo\(\(\) => \{.*?^ \}, \[.*?\]\);",
src, re.M | re.S)
diff --git a/packages/meshbay-hub/tests/test_search_media_merge.py b/packages/meshbay-hub/tests/test_search_media_merge.py
index 3464902..082de7e 100644
--- a/packages/meshbay-hub/tests/test_search_media_merge.py
+++ b/packages/meshbay-hub/tests/test_search_media_merge.py
@@ -50,7 +50,7 @@ EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M)
def _block(path, header):
"""One top-level `function name(...) {` ... `}` read out of a module."""
- src = path.read_text()
+ src = path.read_text(encoding="utf-8")
m = re.search(r"^" + re.escape(header) + r".*?^\}", src, re.M | re.S)
assert m, (
f"{header} is no longer where this test reads it from in {path.name} — "
@@ -60,7 +60,7 @@ def _block(path, header):
def _const(name):
m = re.search(r"^const " + re.escape(name) + r" = .*?;$",
- SEARCH_PAGE.read_text(), re.M)
+ SEARCH_PAGE.read_text(encoding="utf-8"), re.M)
assert m, f"{name} moved — the search-page units cannot be lifted"
return m.group(0)
@@ -70,7 +70,7 @@ def pipeline():
"""Everything the three views need, in one script."""
return "\n".join([
"const t = (k) => k;",
- EXPORT.sub("", MERGE.read_text()),
+ EXPORT.sub("", MERGE.read_text(encoding="utf-8")),
_block(VIDEO_APP, "function underVideoRoot(entry, directories) {"),
_block(VIDEO_APP, "function buildSeasons(episodes) {"),
_block(VIDEO_APP, "function groupVideoEntries(entries, videoDirectories) {"),
@@ -90,9 +90,9 @@ def pipeline():
def _node(tmp_path, pipeline, body):
script = tmp_path / "case.js"
- script.write_text(f"{pipeline}\n{body}\n")
+ script.write_text(f"{pipeline}\n{body}\n", encoding="utf-8")
out = subprocess.run(
- ["node", str(script)], capture_output=True, text=True, timeout=30)
+ ["node", str(script)], capture_output=True, text=True, encoding="utf-8", timeout=30)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
diff --git a/packages/meshbay-hub/tests/test_search_pool.py b/packages/meshbay-hub/tests/test_search_pool.py
index cf9eeb8..89b46cd 100644
--- a/packages/meshbay-hub/tests/test_search_pool.py
+++ b/packages/meshbay-hub/tests/test_search_pool.py
@@ -36,7 +36,7 @@ pytestmark = pytest.mark.skipif(
def _run(**cfg) -> dict:
proc = subprocess.run(
["node", str(HARNESS), search_argv(), json.dumps(cfg)],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_search_source_merge.py b/packages/meshbay-hub/tests/test_search_source_merge.py
index b471891..10d10fc 100644
--- a/packages/meshbay-hub/tests/test_search_source_merge.py
+++ b/packages/meshbay-hub/tests/test_search_source_merge.py
@@ -44,7 +44,7 @@ EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M)
@pytest.fixture(scope="module")
def module_source():
- text = SRC.read_text()
+ text = SRC.read_text(encoding="utf-8")
assert not IMPORT.search(text), (
"source-merge.js has gained an import. It is executed standalone here, "
"and the merge is untested from the moment it cannot be — keep the "
@@ -58,9 +58,9 @@ def module_source():
def _run(tmp_path, module_source, body):
script = tmp_path / "case.js"
- script.write_text(f"{module_source}\n{body}\n")
+ script.write_text(f"{module_source}\n{body}\n", encoding="utf-8")
out = subprocess.run(
- ["node", str(script)], capture_output=True, text=True, timeout=30)
+ ["node", str(script)], capture_output=True, text=True, encoding="utf-8", timeout=30)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
diff --git a/packages/meshbay-hub/tests/test_season_panel_placement.py b/packages/meshbay-hub/tests/test_season_panel_placement.py
index 8c04ea7..6e5f78b 100644
--- a/packages/meshbay-hub/tests/test_season_panel_placement.py
+++ b/packages/meshbay-hub/tests/test_season_panel_placement.py
@@ -43,7 +43,7 @@ BLOCK = re.compile(
@pytest.fixture(scope="module")
def source():
- m = BLOCK.search(APP.read_text())
+ m = BLOCK.search(APP.read_text(encoding="utf-8"))
assert m, ("placeSeasonPanel is no longer where this test reads it from — "
"the season menu's placement is untested until this is fixed")
return m.group(0)
@@ -58,8 +58,8 @@ def _place(tmp_path, source, *, top, bottom, left=40, width=300, inner_height=74
const el = {{ getBoundingClientRect: () => ({{
top: {top}, bottom: {bottom}, left: {left}, width: {width} }}) }};
console.log(JSON.stringify(placeSeasonPanel(el)));
- """)
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ """, encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@@ -157,7 +157,7 @@ def test_no_element_means_no_position(tmp_path, source):
globalThis.window = {{ innerHeight: 740 }};
{source}
console.log(JSON.stringify(placeSeasonPanel(null)));
- """)
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ """, encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout) is None
diff --git a/packages/meshbay-hub/tests/test_session_renewal.py b/packages/meshbay-hub/tests/test_session_renewal.py
index 7b8c108..ecf9ac5 100644
--- a/packages/meshbay-hub/tests/test_session_renewal.py
+++ b/packages/meshbay-hub/tests/test_session_renewal.py
@@ -50,7 +50,7 @@ pytestmark = pytest.mark.skipif(
def _run(scenario: str, app: Path = APP) -> dict:
proc = subprocess.run(
["node", str(HARNESS), str(app), json.dumps({"scenario": scenario})],
- capture_output=True, text=True, timeout=60)
+ capture_output=True, text=True, encoding="utf-8", timeout=60)
assert proc.returncode == 0, f"{proc.stdout}\n{proc.stderr}"
return json.loads(proc.stdout.strip().splitlines()[-1])
@@ -59,14 +59,14 @@ def _run(scenario: str, app: Path = APP) -> dict:
def broken(tmp_path_factory):
"""The client as it shipped: the rotated refresh token dropped."""
out = tmp_path_factory.mktemp("session") / "broken.js"
- src = APP.read_text()
+ src = APP.read_text(encoding="utf-8")
replaced = src.replace(
" refreshToken: data.refresh_token || _auth.refreshToken,",
" refreshToken: _auth.refreshToken,")
assert replaced != src, (
"could not reconstruct the defect — the line it hinged on has moved, "
"and the A/B below would be comparing the fix against itself")
- out.write_text(replaced)
+ out.write_text(replaced, encoding="utf-8")
return out
@@ -186,13 +186,13 @@ def test_the_session_is_much_longer_than_the_token():
def test_renewal_happens_before_expiry_not_after():
"""A margin, so the first click after a long film does not pay for a 401."""
- src = APP.read_text()
+ src = APP.read_text(encoding="utf-8")
import re
margin = int(re.search(r"const TOKEN_RENEW_MARGIN_S = (\d+)", src).group(1))
assert margin >= 300, (
f"{margin} s of margin against a one-hour token is thin: a backgrounded "
"tab has its timers throttled and may not check for minutes")
- assert "visibilitychange" in APP_JS.read_text(), (
+ assert "visibilitychange" in APP_JS.read_text(encoding="utf-8"), (
"nothing re-checks when the tab comes back, which is exactly when the "
"token is most likely to have aged out unnoticed")
@@ -213,7 +213,7 @@ def test_renewing_does_not_tear_down_the_webrtc_connection():
Signing in or out must still re-run it, so the dependency is whether there
is a token, not which one.
"""
- src = GROUP_PAGE.read_text()
+ src = GROUP_PAGE.read_text(encoding="utf-8")
i = src.index("means tearing down the WebRTC connection")
deps = src[i:src.index(");", i)]
assert "Boolean(token)" in deps, (
@@ -228,7 +228,7 @@ def test_the_connection_signs_its_offer_with_a_live_token():
It signs the offer relayed through the hub, where an expired one is a 401
and no connection at all.
"""
- src = GROUP_PAGE.read_text()
+ src = GROUP_PAGE.read_text(encoding="utf-8")
connect = src[src.index("const connect = async () => {"):]
connect = connect[:connect.index("\n };")]
assert "await ensureFreshToken()" in connect, (
diff --git a/packages/meshbay-hub/tests/test_sidebar_node_section.py b/packages/meshbay-hub/tests/test_sidebar_node_section.py
new file mode 100644
index 0000000..371a018
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_sidebar_node_section.py
@@ -0,0 +1,55 @@
+"""
+The sidebar's Node section follows the account's node link, and must follow it
+when it changes -- not only at sign-in.
+
+Reported on a real install: after the first click on Create group, the Node
+section (Node, Create group) was gone from the sidebar until a reload, while
+the group was created and the node ran. `hasNodeKey` was read once per session
+change and never again, so a node linked by the wizard stayed out of the
+sidebar; and the one read there was swallowed its errors, so a session blip
+(a refused renewal, then the desktop app's silent device sign-in) followed by
+one failed request left the section hidden for good.
+
+Read from the source, like the rest of the SPA's wiring tests: the state lives
+inside App, and what matters is the seam between two modules.
+"""
+
+import re
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APP = (STATIC / "app.js").read_text(encoding="utf-8")
+WIZARD = (STATIC / "create-group-page.js").read_text(encoding="utf-8")
+
+
+def _body(src: str, start: str, end: str) -> str:
+ return src.split(start, 1)[1].split(end, 1)[0]
+
+
+def test_the_create_group_page_can_tell_the_app_a_node_was_linked():
+ page = _body(APP, "<${LazyCreateGroupPage}", "/>`;")
+ assert "onNodeLinked=${refreshNodeKey}" in page
+ created = _body(page, "onCreated=${() => {", "}}")
+ assert "refreshNodeKey()" in created
+
+
+def test_the_wizard_says_so_after_each_way_it_links_one():
+ link = _body(WIZARD, "const linkNodeKey = useCallback(", "}, [")
+ assert link.index("/v1/users/me/node_key") < link.index("onNodeLinked()"), (
+ "the app must be told after the hub has the key, not before")
+ start = _body(WIZARD, "const startNode = useCallback(", "}, [")
+ assert start.index("platform.node.start(") < start.index("onNodeLinked()")
+
+
+def test_one_failed_read_does_not_hide_the_section_for_good():
+ refresh = _body(APP, "const refreshNodeKey = useCallback(", "}, [user]);")
+ assert "/pubkeys" in refresh
+ assert not re.search(r"\.catch\(\(\)\s*=>\s*\{\s*\}\)", refresh), (
+ "a swallowed failure leaves hasNodeKey false until a reload")
+ assert "setTimeout(" in refresh, "a failed read is tried again"
+ assert "nodeKeyAskedForRef.current === user" in refresh, (
+ "a late answer must not land on a session that has changed")
+
+
+def test_the_session_effect_uses_the_same_read():
+ assert APP.count("/pubkeys`") == 1, "one place decides hasNodeKey"
diff --git a/packages/meshbay-hub/tests/test_site_basics.py b/packages/meshbay-hub/tests/test_site_basics.py
new file mode 100644
index 0000000..78cad83
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_site_basics.py
@@ -0,0 +1,81 @@
+"""
+The files every website is expected to have at the root of its origin.
+
+They live in the hub's static directory, which is mounted at "/", so every hub
+serves them — meshbay.org included, because its Caddyfile hands the hub every
+path the public site does not name.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+from meshbay_hub.app import create_app
+
+CADDYFILE = Path(__file__).resolve().parents[3] / "packaging" / "caddy" / "meshbay.org.Caddyfile"
+
+
+@pytest.fixture(scope="module")
+def client():
+ return TestClient(create_app())
+
+
+def test_robots_keeps_crawlers_out_of_the_signed_in_views(client):
+ r = client.get("/robots.txt")
+ assert r.status_code == 200
+ assert r.headers["content-type"].startswith("text/plain")
+ rules = re.findall(r"^Disallow:\s*(\S+)", r.text, re.M)
+ assert "/app/" in rules and "/v1/" in rules
+ # A crawler rendering the sign-in page needs its scripts and stylesheet.
+ assert not any(rule in ("/", "/a/", "/app") for rule in rules), rules
+
+
+@pytest.mark.parametrize("path, magic", [
+ ("/favicon.ico", b"\x00\x00\x01\x00"),
+ ("/apple-touch-icon.png", b"\x89PNG"),
+])
+def test_the_icons_are_served_from_the_root(client, path, magic):
+ """Browsers ask for both at the root whether or not a page links them."""
+ r = client.get(path)
+ assert r.status_code == 200
+ assert r.content.startswith(magic), f"{path} is not the image it claims to be"
+
+
+@pytest.mark.skipif(not CADDYFILE.exists(), reason="no Caddyfile in this tree")
+@pytest.mark.parametrize("path", ["/robots.txt", "/favicon.ico", "/apple-touch-icon.png"])
+def test_meshbay_org_sends_them_to_the_hub(path):
+ """The public site owns only the paths its matcher names; a file claimed
+ there would be looked for in /srv/meshbay/site and 404."""
+ m = re.search(r"^\s*@site path (.+)$", CADDYFILE.read_text(encoding="utf-8"), re.M)
+ assert m, "the @site matcher is gone"
+ assert path not in m.group(1).split()
+
+
+def _og(html: str, prop: str) -> str | None:
+ m = re.search(rf'<meta property="og:{prop}" content="([^"]*)">', html)
+ return m and m.group(1)
+
+
+def test_a_link_to_the_hub_previews_with_the_logo():
+ """Messengers draw a link from og:title and og:image, and resolve only an
+ absolute image URL — so it names the hub's public name, and the file is
+ one the hub serves."""
+ from meshbay_hub.config import load_config
+ cfg = load_config()
+ cfg.identity.id = "hub.example.org"
+ client = TestClient(create_app(cfg))
+ for path in ("/", "/app"):
+ html = client.get(path).text
+ assert _og(html, "title") == "MeshBay"
+ assert _og(html, "image") == "https://hub.example.org/og-image.jpg", path
+ image = client.get("/og-image.jpg")
+ assert image.status_code == 200 and image.content.startswith(b"\xff\xd8")
+ assert len(image.content) < 300_000, "too heavy for some messengers to fetch"
+
+
+def test_the_preview_description_fits_in_a_preview():
+ """A preview shows a line or two and cuts the rest; a cut sentence says
+ nothing."""
+ from meshbay_hub.api.webapp import PREVIEW_DESCRIPTION
+ assert len(PREVIEW_DESCRIPTION) <= 60
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
index 087298f..fdedaf8 100644
--- a/packages/meshbay-hub/tests/test_spa_ordering.py
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -132,7 +132,7 @@ COMPONENT_FILES = {
def _component(name: str) -> str:
"""The source of one top-level `function Name(...)`, up to the next one."""
- source = COMPONENT_FILES.get(name, APP).read_text()
+ source = COMPONENT_FILES.get(name, APP).read_text(encoding="utf-8")
start = source.find(f"\nfunction {name}(")
if start == -1:
start = source.find(f"\nexport function {name}(")
@@ -225,7 +225,7 @@ def test_the_uploader_keeps_several_chunks_in_flight():
def test_no_caller_waits_for_one_chunk_at_a_time():
- app = APP.read_text()
+ app = APP.read_text(encoding="utf-8")
assert "uploadChunk(" not in app, (
"a per-chunk await is back in the SPA; use transport.uploadFile()")
@@ -248,7 +248,7 @@ def test_leaving_a_group_hands_the_transport_over_rather_than_closing_it():
def test_signing_out_stops_them():
- app = APP.read_text()
+ app = APP.read_text(encoding="utf-8")
logout = app[app.index(" logout: () => {"):]
logout = logout[:logout.index("navigate('/login')")]
assert "transfers.reset()" in logout, (
@@ -257,7 +257,7 @@ def test_signing_out_stops_them():
def test_the_files_panel_no_longer_carries_its_own_progress_bars():
"""They moved next to the bell, where they stay visible across the app."""
- app = APP.read_text()
+ app = APP.read_text(encoding="utf-8")
for gone in ("setUlState", "setDlState", "dl-bar"):
assert gone not in app, f"{gone} survived the move to the transfer widget"
@@ -270,7 +270,7 @@ def test_a_multi_file_download_waits_for_each_picker():
meant the first opened a dialog and the rest were rejected — two files
selected, one file downloaded.
"""
- app = STATIC.joinpath("files-app.js").read_text()
+ app = STATIC.joinpath("files-app.js").read_text(encoding="utf-8")
# Anchored on the loop rather than on the markup around it: the toolbar
# moved from a dropdown to icon buttons and took the old wrapper with it,
# while the property under test — one picker at a time — did not change.
@@ -289,7 +289,7 @@ def test_links_in_chat_are_built_as_elements_not_markup():
never HTML, and only for http(s) — otherwise javascript: would be one
message away from running here.
"""
- app = STATIC.joinpath("chat-app.js").read_text()
+ app = STATIC.joinpath("chat-app.js").read_text(encoding="utf-8")
fn = app[app.index("function linkify("):]
fn = fn[:fn.index("\nfunction ", 1)]
assert "innerHTML" not in fn and "dangerouslySetInnerHTML" not in fn
diff --git a/packages/meshbay-hub/tests/test_spa_syntax.py b/packages/meshbay-hub/tests/test_spa_syntax.py
index 352f84b..aac4010 100644
--- a/packages/meshbay-hub/tests/test_spa_syntax.py
+++ b/packages/meshbay-hub/tests/test_spa_syntax.py
@@ -47,7 +47,7 @@ def test_every_module_parses(tmp_path):
copy = tmp_path / (path.stem + ".mjs")
copy.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
proc = subprocess.run(["node", "--check", str(copy)],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
if proc.returncode != 0:
first = (proc.stderr or "").strip().splitlines()
detail = next((ln for ln in first if "Error" in ln), first[:1] and first[0] or "")
@@ -67,12 +67,12 @@ def test_the_check_would_notice_a_broken_file(tmp_path):
as_js = tmp_path / "sample.js"
as_js.write_text(bad, encoding="utf-8")
lenient = subprocess.run(["node", "--check", str(as_js)],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
as_mjs = tmp_path / "sample.mjs"
as_mjs.write_text(bad, encoding="utf-8")
strict = subprocess.run(["node", "--check", str(as_mjs)],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
assert strict.returncode != 0, (
"the .mjs check no longer reports a module syntax error — this whole "
diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py
index e1b3800..e60e833 100644
--- a/packages/meshbay-hub/tests/test_streamed_download_reliability.py
+++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py
@@ -195,8 +195,8 @@ def _run(tmp_path, body, *, control_after_ms=0, active=True,
controlled_at_load=False, registered_at_load=False,
worker_asleep=False):
module = tmp_path / "downloads.mjs"
- module.write_text(DOWNLOADS.read_text())
- (tmp_path / "package.json").write_text('{"type":"module"}')
+ module.write_text(DOWNLOADS.read_text(encoding="utf-8"), encoding="utf-8")
+ (tmp_path / "package.json").write_text('{"type":"module"}', encoding="utf-8")
plan = {
"controlAfterMs": control_after_ms,
"active": active,
@@ -211,12 +211,12 @@ def _run(tmp_path, body, *, control_after_ms=0, active=True,
}
script = tmp_path / "case.mjs"
script.write_text(
- (PRELUDE % {"plan": json.dumps(plan), "module": module.as_posix(),
+ (PRELUDE % {"plan": json.dumps(plan), "module": module.as_uri(),
"control": control_budget_ms,
"controlled": json.dumps(controlled_at_load)})
+ body
- + "\nout.log = log;\nconsole.log(JSON.stringify(out));\n")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True,
+ + "\nout.log = log;\nconsole.log(JSON.stringify(out));\n", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8",
timeout=120)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@@ -338,7 +338,7 @@ def test_the_worker_is_primed_at_boot_not_at_the_first_click(tmp_path):
from a module that actually imports it — `node --check` would not notice a
missing import, which is a mistake this repo has already shipped once.
"""
- app = (STATIC / "app.js").read_text()
+ app = (STATIC / "app.js").read_text(encoding="utf-8")
assert "downloads.primeServiceWorker()" in app, "nothing primes the worker"
assert "import * as downloads from './downloads.js'" in app, (
"app.js calls downloads.primeServiceWorker() without importing downloads")
@@ -350,7 +350,7 @@ def test_the_worker_is_primed_at_boot_not_at_the_first_click(tmp_path):
def test_the_worker_answers_a_re_claim(tmp_path):
"""The page's last resort before declaring the path unavailable only works
if sw.js implements the other half."""
- sw = (STATIC / "sw.js").read_text()
+ sw = (STATIC / "sw.js").read_text(encoding="utf-8")
assert "mbdl-claim" in sw and "clients.claim()" in sw
diff --git a/packages/meshbay-hub/tests/test_table_rows_measured.py b/packages/meshbay-hub/tests/test_table_rows_measured.py
index f203624..b1f36ea 100644
--- a/packages/meshbay-hub/tests/test_table_rows_measured.py
+++ b/packages/meshbay-hub/tests/test_table_rows_measured.py
@@ -61,7 +61,7 @@ SELECTORS = [f"tbody tr:nth-child({r}) td:nth-child({c})"
@pytest.fixture(scope="module")
def measured(tmp_path_factory):
fragment = tmp_path_factory.mktemp("table") / "fragment.html"
- fragment.write_text(TABLE)
+ fragment.write_text(TABLE, encoding="utf-8")
proc = subprocess.run(
["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
str(fragment), *SELECTORS],
diff --git a/packages/meshbay-hub/tests/test_token_hardening.py b/packages/meshbay-hub/tests/test_token_hardening.py
new file mode 100644
index 0000000..f381f69
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_token_hardening.py
@@ -0,0 +1,128 @@
+"""A hub-signed token is a session only if it says so, and only while it lasts.
+
+One Ed25519 key signs four kinds of token: user access, node access, revocation
+broadcasts and MHP federation tokens. `decode_access_token` used to accept any
+of them that carried a valid signature, requiring no `exp` and binding no
+purpose — so a token with no expiry was honoured, and the separation between
+the four rested only on which fields each consumer happened to read. A
+revocation token is even handed back in the body of `POST /v1/admin/revoke` and
+pushed to every node, so nodes hold hub-signed tokens.
+
+These are refusals: `decode_access_token` must require `exp`, `sub` and a known
+`scope`, so a token with no expiry, a revocation token, or an MHP token can
+never be mistaken for a session — while a real login token still works.
+"""
+
+import time
+
+import pytest
+
+from meshbay_common.tokens import HUB_API_AUD
+from meshbay_hub import auth
+
+
+def _sk_pem_loaded():
+ # The `client` fixture's app runs load_hub_keypair in its lifespan, so the
+ # module key is loaded by the time a test body runs.
+ return auth._hub_sk_pem is not None
+
+
+# ── Unit-level: the decode contract ──────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_token_without_exp_is_refused(client):
+ import jwt
+ assert _sk_pem_loaded()
+ # A hub-signed token with a valid scope and sub but NO exp.
+ forged = jwt.encode({"sub": "u", "scope": "user", "aud": HUB_API_AUD},
+ auth.hub_private_key_pem(), algorithm="EdDSA")
+ with pytest.raises(Exception):
+ auth.decode_access_token(forged)
+
+
+@pytest.mark.asyncio
+async def test_expired_token_is_refused(client):
+ import jwt
+ forged = jwt.encode({"sub": "u", "scope": "user", "aud": HUB_API_AUD,
+ "exp": int(time.time()) - 100},
+ auth.hub_private_key_pem(), algorithm="EdDSA")
+ with pytest.raises(jwt.ExpiredSignatureError):
+ auth.decode_access_token(forged)
+
+
+@pytest.mark.asyncio
+async def test_token_without_scope_is_refused(client):
+ import jwt
+ forged = jwt.encode({"sub": "u", "exp": int(time.time()) + 3600, "aud": HUB_API_AUD},
+ auth.hub_private_key_pem(), algorithm="EdDSA")
+ with pytest.raises(Exception):
+ auth.decode_access_token(forged)
+
+
+@pytest.mark.asyncio
+async def test_unknown_scope_is_refused(client):
+ import jwt
+ forged = jwt.encode({"sub": "u", "scope": "root", "aud": HUB_API_AUD,
+ "exp": int(time.time()) + 3600},
+ auth.hub_private_key_pem(), algorithm="EdDSA")
+ with pytest.raises(jwt.InvalidTokenError):
+ auth.decode_access_token(forged)
+
+
+@pytest.mark.asyncio
+async def test_an_mnp_token_is_refused_at_the_hub_api(client):
+ """The MNP token (aud=MNP_AUD) authorises a member to a node; it must not be
+ a session at the hub. A node operator holds one, and this is what stops them
+ replaying it against the hub API."""
+ from meshbay_hub.auth import issue_mnp_token
+ mnp = issue_mnp_token("some-user", groups=[])
+ with pytest.raises(Exception):
+ auth.decode_access_token(mnp)
+
+
+@pytest.mark.asyncio
+async def test_a_revocation_token_is_not_a_session(client):
+ """The concrete cross-type case: revocation tokens are hub-signed, carry no
+ exp/sub/scope, and are handed to admins and pushed to every node."""
+ import jwt
+ from meshbay_hub.api.revocation import _sign_revocation
+ rev = _sign_revocation("user", "some-id", "policy")
+ # It is a genuine hub-signed token (signature verifies) ...
+ jwt.decode(rev, auth.hub_public_key_pem(), algorithms=["EdDSA"])
+ # ... but it is not a session.
+ with pytest.raises(Exception):
+ auth.decode_access_token(rev)
+
+
+# ── Endpoint-level: a revocation token gets no access ────────────────────────
+
+@pytest.mark.asyncio
+async def test_revocation_token_gets_no_api_access(client):
+ from meshbay_hub.api.revocation import _sign_revocation
+ rev = _sign_revocation("user", "x", "policy")
+ r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {rev}"})
+ assert r.status_code == 401
+
+
+# ── The path that must keep working ──────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_real_login_token_still_works(client):
+ await client.post("/v1/users/register", json={
+ "username": "live_token_test", "email": "l@test.local", "auth_key": "k" * 44})
+ tok = (await client.post("/v1/users/login", json={
+ "username": "live_token_test", "auth_key": "k" * 44})).json()["access_token"]
+ dec = auth.decode_access_token(tok)
+ assert dec["scope"] == "user" and "exp" in dec and "sub" in dec
+ r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {tok}"})
+ assert r.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_a_node_token_still_decodes(client):
+ """Node access tokens carry scope=node/exp/sub and must keep decoding — the
+ node decodes its own token, and the hub decodes it on the WS."""
+ from meshbay_hub.auth import issue_access_token
+ tok = issue_access_token("nid", ttl=3600, groups=[], scope="node")
+ dec = auth.decode_access_token(tok)
+ assert dec["scope"] == "node"
diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py
index b1bac4a..1d0c0f0 100644
--- a/packages/meshbay-hub/tests/test_transfers.py
+++ b/packages/meshbay-hub/tests/test_transfers.py
@@ -26,15 +26,15 @@ pytestmark = pytest.mark.skipif(
def _run(body, tmp_path):
module = tmp_path / "transfers.mjs"
- module.write_text(TRANSFERS.read_text())
+ module.write_text(TRANSFERS.read_text(encoding="utf-8"), encoding="utf-8")
script = tmp_path / "case.mjs"
script.write_text(
- f"import {{ TransferStore, formatSpeed }} from '{module.as_posix()}';\n"
+ f"import {{ TransferStore, formatSpeed }} from '{module.as_uri()}';\n"
"const out = [];\n"
"const say = (...a) => out.push(...a);\n"
f"{body}\n"
- "console.log(JSON.stringify(out));\n")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ "console.log(JSON.stringify(out));\n", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@@ -388,11 +388,11 @@ def test_asking_for_a_slot_on_a_dead_channel_does_not_throw(tmp_path):
# first time it arms its watchdog.
start = src.index("const LEASE_WATCHDOG_MS")
end = src.index("\nclass MeshBayTransport")
- module.write_text(src[start:end] + "\nexport { Lease };\n")
+ module.write_text(src[start:end] + "\nexport { Lease };\n", encoding="utf-8")
script = tmp_path / "case.mjs"
script.write_text(f"""
-import {{ Lease }} from '{module.as_posix()}';
+import {{ Lease }} from '{module.as_uri()}';
const out = [];
const transport = {{
supportsTransferSlots: true,
@@ -409,8 +409,8 @@ try {{ lease.release('cancelled'); out.push('release ok'); }}
catch (e) {{ out.push('release threw: ' + e.message); }}
clearTimeout(lease._watchdog);
console.log(JSON.stringify(out));
-""")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
assert out[0] is None, f"asking for a slot threw: {out[0]}"
@@ -433,7 +433,7 @@ def test_the_slot_is_asked_for_after_there_is_somewhere_to_write():
Source-reading, because the ordering is the whole property and it has no
behaviour of its own to drive: what matters is which call comes first.
"""
- src = (STATIC / "file-utils.js").read_text()
+ src = (STATIC / "file-utils.js").read_text(encoding="utf-8")
fn = src[src.index("async function downloadEntry"):]
fn = fn[:fn.index("\n}\n")]
# `_openTargetInTurn` since target openings were serialised — same call,
@@ -832,7 +832,7 @@ def test_a_paused_transfer_is_not_filed_under_finished(tmp_path):
here: a copy of them in this file would agree with a broken version by
construction.
"""
- src = (STATIC / "app.js").read_text()
+ src = (STATIC / "app.js").read_text(encoding="utf-8")
start = src.index(" const running = items.filter(")
block = src[start:src.index("const active =", start)]
@@ -851,8 +851,8 @@ const items = [
const seen = { running, waiting, paused, finished };
console.log(JSON.stringify(Object.fromEntries(
Object.entries(seen).map(([k, v]) => [k, v.map(i => i.id)]))));
-""")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
groups = json.loads(proc.stdout)
@@ -870,7 +870,7 @@ def test_a_paused_transfer_still_counts_as_active(tmp_path):
"""The badge says how much is going on. A paused transfer is not over — the
person means to come back to it — so counting it as nothing would be a
panel that says "0" over work that is still there."""
- src = (STATIC / "app.js").read_text()
+ src = (STATIC / "app.js").read_text(encoding="utf-8")
start = src.index(" const running = items.filter(")
block = src[start:src.index("\n\n", src.index("const active =", start))]
@@ -879,8 +879,8 @@ def test_a_paused_transfer_still_counts_as_active(tmp_path):
const items = [{ id: 1, status: 'paused' }, { id: 2, status: 'done' }];
""" + block + """
console.log(JSON.stringify({ active }));
-""")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout)["active"] == 1
@@ -897,7 +897,7 @@ def test_a_row_that_cannot_pause_says_so_where_the_button_would_be():
Shown only where a folder can actually be chosen: Firefox and Safari have
none to choose, and "choose a folder" would be advice that cannot be taken.
"""
- src = (STATIC / "app.js").read_text()
+ src = (STATIC / "app.js").read_text(encoding="utf-8")
row = src[src.index("function TransferRow"):]
row = row[:row.index("\n}\n")]
@@ -917,4 +917,4 @@ def test_the_reason_is_translated_everywhere():
"""`t()` falls back to the key, so a missing catalogue entry shows
`transfers.not_pausable` in a tooltip rather than a sentence."""
for path in sorted((STATIC / "locales").glob("*.js")):
- assert "'transfers.not_pausable'" in path.read_text(), path.name
+ assert "'transfers.not_pausable'" in path.read_text(encoding="utf-8"), path.name
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index 978c2e5..c1ca36b 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -59,22 +59,22 @@ def transport():
@pytest.fixture(scope="module")
def app():
- return APP.read_text()
+ return APP.read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def chat():
- return CHAT_APP.read_text()
+ return CHAT_APP.read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def group_page():
- return GROUP_PAGE.read_text()
+ return GROUP_PAGE.read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def create_group():
- return CREATE_GROUP.read_text()
+ return CREATE_GROUP.read_text(encoding="utf-8")
def test_chat_history_pages_backwards(transport):
@@ -187,7 +187,7 @@ def test_messages_are_keyed_by_id_not_index(chat):
def test_presence_has_three_states_and_a_label_for_each(app):
for state in ("online", "offline", "unknown"):
- assert f"presence-{state}" in (STATIC / "style.css").read_text()
+ assert f"presence-{state}" in (STATIC / "style.css").read_text(encoding="utf-8")
assert "t('presence.' + state)" in app, (
"red and green are the pair colour-blind readers cannot separate, so "
"the dot needs a title and an aria-label, not just a colour")
@@ -278,7 +278,7 @@ def test_no_setter_survives_the_state_it_belonged_to():
"""
import re
for path in SPLIT_FILES:
- app = path.read_text()
+ app = path.read_text(encoding="utf-8")
declared = set(re.findall(r"const \[\s*\w+\s*,\s*(set\w+)\s*\]\s*=\s*useState", app))
# Names brought in from another module are defined, just not here.
imported = set()
diff --git a/packages/meshbay-hub/tests/test_upload_seal_client.py b/packages/meshbay-hub/tests/test_upload_seal_client.py
index bcda14c..f62aa68 100644
--- a/packages/meshbay-hub/tests/test_upload_seal_client.py
+++ b/packages/meshbay-hub/tests/test_upload_seal_client.py
@@ -48,10 +48,10 @@ BODY = bytes(range(256)) * 3 # 768 bytes → 24 chunks of 32
def _run_probe(payload: dict) -> dict:
with tempfile.TemporaryDirectory() as d:
f = Path(d) / "input.json"
- f.write_text(json.dumps(payload))
+ f.write_text(json.dumps(payload), encoding="utf-8")
proc = subprocess.run(
["node", str(PROBE), str(STATIC), str(f), transport_argv()],
- capture_output=True, text=True, timeout=60,
+ capture_output=True, text=True, encoding="utf-8", timeout=60,
)
if proc.returncode != 0 or not proc.stdout:
pytest.fail(f"upload probe failed:\n{proc.stderr}")
diff --git a/packages/meshbay-hub/tests/test_versions_agree.py b/packages/meshbay-hub/tests/test_versions_agree.py
index 4466c93..46887c0 100644
--- a/packages/meshbay-hub/tests/test_versions_agree.py
+++ b/packages/meshbay-hub/tests/test_versions_agree.py
@@ -27,11 +27,11 @@ PACKAGES = ROOT / "packages"
def _python_versions() -> dict[str, str]:
found = {}
for pyproject in sorted(PACKAGES.glob("*/pyproject.toml")):
- m = re.search(r'^version = "([^"]+)"', pyproject.read_text(), re.M)
+ m = re.search(r'^version = "([^"]+)"', pyproject.read_text(encoding="utf-8"), re.M)
if m:
found[f"{pyproject.parent.name}/pyproject.toml"] = m.group(1)
for init in sorted(PACKAGES.glob("*/src/*/__init__.py")):
- m = re.search(r'^__version__ = "([^"]+)"', init.read_text(), re.M)
+ m = re.search(r'^__version__ = "([^"]+)"', init.read_text(encoding="utf-8"), re.M)
if m:
found[f"{init.parent.name}/__init__.py"] = m.group(1)
return found
@@ -41,7 +41,7 @@ def _client_version() -> str | None:
pkg = PACKAGES / "meshbay-client" / "package.json"
if not pkg.exists():
return None
- return json.loads(pkg.read_text()).get("version")
+ return json.loads(pkg.read_text(encoding="utf-8")).get("version")
@pytest.mark.skipif(not PACKAGES.is_dir(), reason="package layout not present")
diff --git a/packages/meshbay-hub/tests/test_video_audio_track.py b/packages/meshbay-hub/tests/test_video_audio_track.py
index 54beca2..82d6e18 100644
--- a/packages/meshbay-hub/tests/test_video_audio_track.py
+++ b/packages/meshbay-hub/tests/test_video_audio_track.py
@@ -42,7 +42,7 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture(scope="module")
def app():
- return APP.read_text()
+ return APP.read_text(encoding="utf-8")
def _player(app: str) -> str:
@@ -88,10 +88,10 @@ def _label_cases(tmp_path, app, cases, locale="en"):
"const t = (k, p) => `${k}:${p.n}`;\n"
+ src
+ "\nconst out = JSON.parse(process.argv[2]).map(audioTrackLabel);\n"
- "console.log(JSON.stringify(out));\n")
+ "console.log(JSON.stringify(out));\n", encoding="utf-8")
proc = subprocess.run(
["node", str(script), json.dumps(cases)],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_video_buffer_ceiling.py b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py
index a488976..da9766e 100644
--- a/packages/meshbay-hub/tests/test_video_buffer_ceiling.py
+++ b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py
@@ -67,7 +67,7 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture(scope="module")
def app():
- return APP.read_text()
+ return APP.read_text(encoding="utf-8")
def _player(app: str) -> str:
@@ -95,7 +95,7 @@ HARNESS = Path(__file__).parent / "harness" / "mse_harness.mjs"
def _harness(**cfg) -> dict:
proc = subprocess.run(
["node", str(HARNESS), str(APP), json.dumps(cfg)],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@@ -437,7 +437,7 @@ def test_credit_is_a_window_and_not_a_debt(app):
pump = pump[:pump.index("\n }, [")]
assert "STREAM_WINDOW - outstandingRef.current" in pump, (
"pump() no longer tops a window up to what is allowed in flight")
- src = APP.read_text()
+ src = APP.read_text(encoding="utf-8")
window = int(re.search(r"const STREAM_WINDOW = (\d+)", src).group(1))
assert 2 <= window <= 16, (
f"a window of {window} segments is either too small to keep the pipe "
diff --git a/packages/meshbay-hub/tests/test_video_default_season.py b/packages/meshbay-hub/tests/test_video_default_season.py
index 898d3f5..ab1d7c1 100644
--- a/packages/meshbay-hub/tests/test_video_default_season.py
+++ b/packages/meshbay-hub/tests/test_video_default_season.py
@@ -43,7 +43,7 @@ BLOCK = re.compile(r"^function defaultSeason\(show\) \{.*?^\}", re.M | re.S)
@pytest.fixture(scope="module")
def source():
- m = BLOCK.search(APP.read_text())
+ m = BLOCK.search(APP.read_text(encoding="utf-8"))
assert m, ("defaultSeason is no longer where this test reads it from — the "
"season a show opens on is untested until this is fixed")
return m.group(0)
@@ -57,8 +57,8 @@ def _default(tmp_path, source, seasons):
script.write_text(f"""
{source}
console.log(JSON.stringify(defaultSeason({json.dumps(show)})));
- """)
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ """, encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_video_detail_measured.py b/packages/meshbay-hub/tests/test_video_detail_measured.py
index 1ac8586..4cd5969 100644
--- a/packages/meshbay-hub/tests/test_video_detail_measured.py
+++ b/packages/meshbay-hub/tests/test_video_detail_measured.py
@@ -182,7 +182,7 @@ SELECTORS = [
def measured(tmp_path_factory):
"""One browser for every width — launching one apiece cost three minutes."""
fragment = tmp_path_factory.mktemp("videodetail") / "fragment.html"
- fragment.write_text(FRAGMENT)
+ fragment.write_text(FRAGMENT, encoding="utf-8")
proc = subprocess.run(
["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
str(fragment), *SELECTORS],
@@ -292,7 +292,7 @@ def test_the_episode_list_reserves_its_scrollbar():
rectangle — `test_layout_responsive.py` says so at length — but it is
worth more than a test that cannot fail.
"""
- css = (STATIC / "style.css").read_text()
+ css = (STATIC / "style.css").read_text(encoding="utf-8")
rule = re.search(
r"\.video-detail\.video-detail-steady \.video-season-list \{([^}]*)\}", css)
assert rule, "the steady episode-list rule is gone"
diff --git a/packages/meshbay-hub/tests/test_video_reconnect.py b/packages/meshbay-hub/tests/test_video_reconnect.py
index beeeace..91aa87b 100644
--- a/packages/meshbay-hub/tests/test_video_reconnect.py
+++ b/packages/meshbay-hub/tests/test_video_reconnect.py
@@ -53,7 +53,7 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture(scope="module")
def app():
- return APP.read_text()
+ return APP.read_text(encoding="utf-8")
def _player(app: str) -> str:
@@ -87,7 +87,7 @@ def _plan(app: str, cases: list[dict]) -> list[dict]:
"(c) => reconnectPlan(c.playhead, c.range, c.ended, c.cast))));"
)
proc = subprocess.run(["node", "--input-type=module", "-e", script],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_video_seek.py b/packages/meshbay-hub/tests/test_video_seek.py
index 9436065..3289eda 100644
--- a/packages/meshbay-hub/tests/test_video_seek.py
+++ b/packages/meshbay-hub/tests/test_video_seek.py
@@ -46,7 +46,7 @@ pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailabl
@pytest.fixture(scope="module")
def app():
- return APP.read_text()
+ return APP.read_text(encoding="utf-8")
def _player(app: str) -> str:
@@ -164,7 +164,7 @@ def test_the_leak_deadlocks_the_window_and_the_fix_clears_it():
proc = subprocess.run(
["node", str(harness), str(APP),
json.dumps({"decrementFirst": decrement_first})],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@@ -269,7 +269,7 @@ def test_seeks_are_debounced(app):
"""Dragging fires `seeking` continuously; each one we act on costs a spawn."""
player = _player(app)
assert "SEEK_DEBOUNCE_MS" in player, "every intermediate drag position seeks"
- ms = int(re.search(r"const SEEK_DEBOUNCE_MS = (\d+)", APP.read_text()).group(1))
+ ms = int(re.search(r"const SEEK_DEBOUNCE_MS = (\d+)", APP.read_text(encoding="utf-8")).group(1))
assert 150 <= ms <= 1000, (
f"{ms} ms is either short enough to still storm the node or long "
"enough to feel broken")
@@ -289,7 +289,7 @@ def test_a_seek_inside_the_buffer_does_not_reach_the_node(app):
def test_the_position_is_kept_in_this_browser(app):
"""localStorage: no protocol, no storage for anyone else to keep, and
nothing new learns what you watch."""
- src = APP.read_text()
+ src = APP.read_text(encoding="utf-8")
assert "mb:pos:" in src, "no position is stored"
read = src[src.index("function readResumePosition"):]
read = read[:read.index("\n}")]
@@ -299,7 +299,7 @@ def test_the_position_is_kept_in_this_browser(app):
def test_a_finished_film_does_not_offer_to_resume(app):
- src = APP.read_text()
+ src = APP.read_text(encoding="utf-8")
write = src[src.index("function writeResumePosition"):]
write = write[:write.index("\n}")]
assert "RESUME_MAX_FRACTION" in write and "removeItem" in write, (
@@ -317,6 +317,6 @@ def test_the_viewer_can_refuse_the_resume(app):
@pytest.mark.parametrize("locale", ["en", "fr", "es", "pt-BR", "zh-CN", "ja",
"de", "it", "nl", "pl"])
def test_the_resume_strings_exist_everywhere(locale):
- text = (STATIC / "locales" / f"{locale}.js").read_text()
+ text = (STATIC / "locales" / f"{locale}.js").read_text(encoding="utf-8")
for key in ("video.resumed_at", "video.from_start"):
assert key in text, f"{locale} is missing {key}"
diff --git a/packages/meshbay-hub/tests/test_video_series_stays_open.py b/packages/meshbay-hub/tests/test_video_series_stays_open.py
new file mode 100644
index 0000000..e0fe3f0
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_video_series_stays_open.py
@@ -0,0 +1,74 @@
+"""
+A show's detail modal stays open under the player.
+
+Playing an episode closed the modal, so watching the next one meant finding the
+show's card again, opening it, and picking the season again — every episode.
+The modal now stays where it was, on the same season, with the episode just
+started marked, and the player is drawn over it.
+
+Measured rather than read: whether the reader lands back on the modal depends on
+`PosterGrid`, on `GroupPage` mounting the player beside it, and on which of two
+`.video-overlay`s the stylesheet puts on top. The probe renders the shipped
+`GroupPage` against a stub node and walks it.
+"""
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "video_series_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "video-app.js").exists(),
+ reason="Chrome or the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def walk():
+ run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=150)
+ assert run.returncode == 0, run.stderr.decode()[-2000:]
+ out = json.loads(run.stdout.decode())
+ assert "error" not in out, out["error"]
+ return out
+
+
+def test_the_player_is_drawn_over_the_modal(walk):
+ assert walk["playing"]["detail"], "the show's modal closed when an episode started"
+ assert walk["playing"]["player"]
+ assert walk["playing"]["topmost"] == "player"
+
+
+@pytest.mark.parametrize("step", ["afterEscape", "afterClose"])
+def test_closing_the_player_lands_back_on_the_season_being_watched(walk, step):
+ after = walk[step]
+ assert after["detail"] and not after["player"]
+ assert after["topmost"] == "detail"
+ assert after["season"] == walk["playing"]["season"], "the season picked was lost"
+ assert after["rows"] == walk["playing"]["rows"]
+
+
+def test_the_episode_just_started_is_marked(walk):
+ rows = walk["playing"]["rows"]
+ assert walk["playing"]["marked"] == [rows[1]]
+ assert walk["afterEscape"]["marked"] == [rows[1]]
+ # Starting the next one from the modal moves the mark with it.
+ assert walk["afterClose"]["marked"] == [rows[2]]
+
+
+def test_the_modal_still_closes_and_reopens_clean(walk):
+ assert not walk["afterModalClose"]["detail"]
+ reopened = walk["reopened"]
+ assert reopened["detail"]
+ assert reopened["marked"] == [], "a mark from the last visit carried over"
+ assert reopened["season"] != walk["playing"]["season"], (
+ "a reopened show starts on its default season, as it always did")
+
+
+def test_a_film_still_closes_its_modal(walk):
+ """Nothing left to pick once a film starts, so nothing to come back to."""
+ assert walk["film"]["player"]
+ assert not walk["film"]["detail"]
+ assert walk["film"]["topmost"] == "player"
diff --git a/packages/meshbay-hub/tests/test_video_stream_switch.py b/packages/meshbay-hub/tests/test_video_stream_switch.py
index 7859057..6c67430 100644
--- a/packages/meshbay-hub/tests/test_video_stream_switch.py
+++ b/packages/meshbay-hub/tests/test_video_stream_switch.py
@@ -45,7 +45,7 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture(scope="module")
def app():
- return APP.read_text()
+ return APP.read_text(encoding="utf-8")
def _player(app: str) -> str:
@@ -125,8 +125,8 @@ def test_the_stall_is_reproduced_and_the_reset_clears_it(tmp_path):
out[name] = p.st.appended - afterFirst;
}
console.log(JSON.stringify(out));
- """)
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ """, encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
got = json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_video_subtitles.py b/packages/meshbay-hub/tests/test_video_subtitles.py
index 5a4b215..68058cd 100644
--- a/packages/meshbay-hub/tests/test_video_subtitles.py
+++ b/packages/meshbay-hub/tests/test_video_subtitles.py
@@ -47,7 +47,7 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture(scope="module")
def app():
- return APP.read_text()
+ return APP.read_text(encoding="utf-8")
@pytest.fixture(scope="module")
@@ -117,10 +117,10 @@ def _label_cases(tmp_path, app, cases, locale="en"):
"const t = (k, p) => (p ? `${k}:${p.n}` : k);\n"
+ src
+ "\nconst out = JSON.parse(process.argv[2]).map(subtitleTrackLabel);\n"
- "console.log(JSON.stringify(out));\n")
+ "console.log(JSON.stringify(out));\n", encoding="utf-8")
proc = subprocess.run(
["node", str(script), json.dumps(cases)],
- capture_output=True, text=True)
+ capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_welcome_layout_measured.py b/packages/meshbay-hub/tests/test_welcome_layout_measured.py
index da72f34..5a77cb9 100644
--- a/packages/meshbay-hub/tests/test_welcome_layout_measured.py
+++ b/packages/meshbay-hub/tests/test_welcome_layout_measured.py
@@ -40,34 +40,46 @@ def _items(*keys: str) -> str:
def _page() -> str:
li = _items
+ # The source row carries the full URL rather than the host name it shows:
+ # an unbreakable string longer than the real one.
+ docs = "".join(
+ f'<li><span class="welcome-docs-label">{_en(label)}</span>'
+ f'<span class="welcome-docs-links">{links}</span></li>'
+ for label, links in [
+ ("welcome.docs_source", "https://git.meshbay.org/meshbay.git/about/"),
+ ("welcome.docs_user", f"{_en('welcome.docs_quickstart')} · {_en('welcome.docs_userguide')}"),
+ ("welcome.docs_devel", f"{_en('welcome.docs_design')} · {_en('welcome.docs_protocol')}"),
+ ])
return textwrap.dedent(f"""
<nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div>
<div class="nav-right"><button class="nav-btn">Login</button></div></nav>
<div class="layout"><main class="main"><div class="page-center"><div class="welcome">
- <div class="card login-card"><h2>Login</h2>
+ <div class="welcome-side"><div class="card login-card"><h2>Login</h2>
<form><input type="text" placeholder="Username" />
<input type="password" placeholder="Password" /><button>Login</button></form>
<div class="login-footer">No account? <a href="#/register">Register</a></div>
<div class="login-footer"><a href="#/reset">Forgot your passphrase?</a></div>
</div>
+ <div class="welcome-links"><a class="welcome-cta" href="#">{_en('welcome.download')}</a>
+ <a class="welcome-legal" href="#">{_en('welcome.legal')}</a></div></div>
<section class="welcome-pitch">
<h1 class="welcome-title">{_en('welcome.title')}</h1>
<p class="welcome-lead">{_en('welcome.lead')}</p>
<ul class="welcome-apps">{li('welcome.app_chat', 'welcome.app_photos',
'welcome.app_media', 'welcome.app_video', 'welcome.app_music')}</ul>
- <p>{_en('welcome.groups')}</p>
- <p class="welcome-e2e"><span>{_en('welcome.e2e')}</span></p>
+ <h2 class="welcome-h">{_en('welcome.how_title')}</h2>
+ <ol class="welcome-steps">{li('welcome.step_home', 'welcome.step_anywhere',
+ 'welcome.step_share')}</ol>
<h2 class="welcome-h">{_en('welcome.uses_title')}</h2>
<ul class="welcome-uses">{li('welcome.use_chat', 'welcome.use_photos',
'welcome.use_media', 'welcome.use_apps')}</ul>
- <div class="welcome-hub"><h2 class="welcome-h">{_en('welcome.hub_title')}</h2>
- <p>{_en('welcome.hub_body')}</p>
- <p class="welcome-hub-never">{_en('welcome.hub_never')}</p>
- <ul class="welcome-never">{li('welcome.never_transit', 'welcome.never_stored',
- 'welcome.never_e2e')}</ul>
- <p class="welcome-hub-free">{_en('welcome.hub_free')}</p></div>
- <div class="welcome-links"><a class="welcome-cta" href="#">{_en('welcome.download')}</a>
- <a class="welcome-legal" href="#">{_en('welcome.legal')}</a></div>
+ <div class="welcome-private"><span class="welcome-private-icon"></span><div>
+ <h2 class="welcome-private-title">{_en('welcome.private_title')}</h2>
+ <p>{_en('welcome.private_body')}</p>
+ <ul class="welcome-badges">{li('welcome.badge_free', 'welcome.badge_open',
+ 'welcome.badge_no_ads', 'welcome.badge_no_tracking')}</ul></div></div>
+ <div class="welcome-docs"><h2 class="welcome-h">{_en('welcome.docs_title')}</h2>
+ <ul>{docs}</ul></div>
</section>
</div></div></main></div>
""")
@@ -76,7 +88,8 @@ def _page() -> str:
PHONES = [320, 360, 412]
DESKTOPS = [1100, 1440]
WIDTHS = PHONES + [768] + DESKTOPS
-SELECTORS = [".welcome", ".login-card", ".welcome-pitch"]
+SELECTORS = [".welcome", ".login-card", ".welcome-pitch", ".welcome-steps", ".welcome-docs",
+ ".welcome-links"]
@pytest.fixture(scope="module")
@@ -140,3 +153,14 @@ def test_the_pair_is_centred_in_the_window(measured):
middle = measured["1440"]["docScrollW"] / 2
centre = box["left"] + box["width"] / 2
assert abs(centre - middle) <= 2, f"the page is centred on x={centre}, not {middle}"
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_the_download_and_legal_links_sit_under_the_form(measured, width):
+ card, links = _box(measured, width, ".login-card"), _box(measured, width, ".welcome-links")
+ assert links["top"] >= card["top"] + card["height"], (
+ f"at {width} px the links are not below the sign-in form")
+ assert links["top"] - (card["top"] + card["height"]) <= 40, (
+ f"at {width} px the links are far below the form")
+ assert abs(links["left"] - card["left"]) <= 1 and abs(links["width"] - card["width"]) <= 1, (
+ f"at {width} px the links are not aligned with the form")
diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py
index 9243fd1..6ef0989 100644
--- a/packages/meshbay-hub/tests/test_zip_size_limit.py
+++ b/packages/meshbay-hub/tests/test_zip_size_limit.py
@@ -46,7 +46,7 @@ def _run(total_bytes, tmp_path, picker=False):
for src in STATIC.glob("*.js"):
(sandbox / src.name).write_text(src.read_text(encoding="utf-8"),
encoding="utf-8")
- (tmp_path / "package.json").write_text('{"type":"module"}')
+ (tmp_path / "package.json").write_text('{"type":"module"}', encoding="utf-8")
# ask.js draws a dialog in the DOM, which Node has none of; the question is
# answered here instead, exactly where `confirm` used to be stubbed.
(sandbox / "ask.js").write_text(
@@ -87,7 +87,7 @@ if ({picker_js}) {{
}});
}}
-const M = await import('{(sandbox / "file-utils.js").as_posix()}');
+const M = await import('{(sandbox / "file-utils.js").as_uri()}');
// Faithful enough to the real store: it runs `prepare` and honours what it
// returns. The target is opened there now — the row exists from the click and
@@ -126,7 +126,7 @@ out.limit = M.ZIP_MAX_BYTES;
console.log(JSON.stringify(out));
""", encoding="utf-8")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
diff --git a/packages/meshbay-hub/tests/test_zipstream.py b/packages/meshbay-hub/tests/test_zipstream.py
index 51a42d0..55a4d97 100644
--- a/packages/meshbay-hub/tests/test_zipstream.py
+++ b/packages/meshbay-hub/tests/test_zipstream.py
@@ -32,12 +32,12 @@ def _build(files, force_zip64=False, tmp_path=None):
# <script type="module">, but Node reads a bare .js as CommonJS unless a
# package.json says otherwise, and there is none next to the SPA.
module = tmp_path / "zipstream.mjs"
- module.write_text(ZIPSTREAM.read_text())
+ module.write_text(ZIPSTREAM.read_text(encoding="utf-8"), encoding="utf-8")
script = tmp_path / "build.mjs"
out = tmp_path / "out.zip"
script.write_text(f"""
import {{ writeFileSync }} from 'node:fs';
-import {{ ZipStream }} from '{module.as_posix()}';
+import {{ ZipStream }} from '{module.as_uri()}';
const files = {json.dumps({k: list(v) for k, v in files.items()})};
const parts = [];
@@ -55,8 +55,8 @@ for (const [name, bytes] of Object.entries(files)) {{
}}
await zip.finish();
writeFileSync('{out.as_posix()}', Buffer.concat(parts));
-""")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return out.read_bytes()
@@ -140,14 +140,14 @@ def test_an_empty_archive_is_still_an_archive(tmp_path):
def _under(entries, dir_, tmp_path):
module = tmp_path / "zipstream.mjs"
- module.write_text(ZIPSTREAM.read_text())
+ module.write_text(ZIPSTREAM.read_text(encoding="utf-8"), encoding="utf-8")
script = tmp_path / "under.mjs"
script.write_text(f"""
-import {{ entriesUnder }} from '{module.as_posix()}';
+import {{ entriesUnder }} from '{module.as_uri()}';
const out = entriesUnder({json.dumps(entries)}, {json.dumps(dir_)});
console.log(JSON.stringify(out.map(o => o.name)));
-""")
- proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+""", encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True, encoding="utf-8")
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)