Skip to content

LLM appliance: hub daemon, mesh chat, shared join widget, CI/CD to httpeers.net - #4

Merged
mkotelnikov merged 72 commits into
mainfrom
llm-appliance
Sep 18, 2026
Merged

mkotelnikov merged 72 commits into
mainfrom
llm-appliance

Conversation

@mkotelnikov

@mkotelnikov mkotelnikov commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

What this is

The LLM appliance, the mesh chat that uses it, and now its production deployment:

  • apps/hub: the Node hub daemon. It has a persistent identity, rules, service modules (llm, a LiteLLM passthrough that mints keys), a local door, an admin REST API and a local admin UI.
  • apps/llm-chat: a chat for any OpenAI-compatible API. It runs standalone (index.html) or over the mesh (mesh.html: join, discover the hub's LLM service, chat).
  • deploy/llm-appliance: hub, LiteLLM, Postgres and Traefik in compose. It uses the relay-circuit fallback when WebRTC fails.
  • New in this round:
    • CI/CD to httpeers.net. .github/workflows/llm-appliance.yml publishes ghcr.io/statewalker/httpeers-hub (the image carries the appliance's compose files under /appliance). A forced-command SSH key then deploys it to /opt/httpeers-llm on the server, with verification and rollback.
    • OpenRouter models (compose.server.yml, litellm/config.server.yaml).
    • packages/httpeers-join: one shared join widget, used by mesh.html and by the demos' app, images and proxy pages.

CI/CD mechanism

  • Push to main touching the hub, the packages, deploy/llm-appliance or the lockfile: publish pushes sha-<commit> and latest. Then deploy runs, gated on vars.LLM_DEPLOY_ENABLED == 'true', and SSHes in with LLM_DEPLOY_SSH_KEY.
  • The key is authorised as restrict,command="/opt/httpeers-llm/bin/deploy.sh". It can only pass a commit sha.
  • On the server the script pulls that sha's image, extracts /appliance into releases/<sha>, and runs compose up --wait. It then checks that the peerId is unchanged, that health.sh passes, and that LiteLLM lists models. When a check fails it rolls back.
  • Why not the relay's SSH pattern or a pull-based updater: see deploy/llm-appliance/README.md, "On the httpeers.net server".
  • Pull requests build the image without pushing it.

New configuration (names only): secret LLM_DEPLOY_SSH_KEY, variables LLM_DEPLOY_ENABLED=true and DEPLOY_KNOWN_HOSTS. It reuses the existing secrets DEPLOY_HOST and DEPLOY_USER. The OpenRouter key lives only in the server's /opt/httpeers-llm/.env (mode 600).

Merging this deploys. It republishes the hub image as latest and redeploys the appliance from main. The lockfile changes, so the relay and sites workflows also rebuild and redeploy (a brief relay reconnect).

Verified

  • CD. It was exercised from this branch before merge through a temporary trigger, since reverted. Five deploy runs: one failed on LiteLLM's slow first start, which led to the fix in compose.server.yml; one failed on a Traefik readiness race, where the rollback path ran and the fix is in deploy.sh; the rest passed.
  • E2E on the real domains (deploy/llm-appliance/e2e/RESULTS.md): all six steps pass against the server, through https://llm-chat.httpeers.net/mesh.html and relay.httpeers.net, with browsers on a different network from the server:
    • admin join with a direct link, key mint, and a streamed gpt-4o-mini reply;
    • the LiteLLM dashboard over the mesh;
    • a member on the relay path refused admin paths, then chatting;
    • revocation effective 65 ms after the DELETE.
  • Join widget:
    • 38 package tests (happy-dom, every phase, and a runtime import of dist/);
    • workspace build, typecheck and tests green (run in the branch's worktree);
    • join-smoke for the demos;
    • a live paste-to-join on the published mesh.html, joined direct in 1.25 s.
  • Not verified: a phone on mobile data against the server hub, and a real camera QR scan (headless has no camera).

Not in this PR

  • The demos pages with the widget are not published (another branch is publishing hub. and app.).
  • *.p.httpeers.net is Session origins: {name}.p.httpeers.net shell, demo, infra #3.
  • Open question. The first PR CI run failed in apps/hub/tests/daemon.test.ts cleanup with ENOTEMPTY on hub/state, which means a state write landed after stop() resolved. The cleanup now retries, and the check passes. Whether the daemon should await its pending state writes in stop() is left open.

🤖 Generated with Claude Code

mkotelnikov and others added 30 commits September 15, 2026 09:06
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t suites

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…y only when it has one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…over the session store

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…page, shared sessions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…red: no UI yet)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ngs and model dialogs, session list

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…esh shows inline

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…requests

apps/demos depends on it via catalog:, so a standalone install failed with ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC; 0.9.0 is on npm, the same range as webrun-files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ActionBarPrimitive.Reload is enabled on every assistant message, not just the
last one, but onReload ignored the clicked message's parentId and always
called controller.regenerate() — replacing the LATEST reply even when an
earlier one was clicked.

Add ChatController.regenerateFrom(index): truncates to
messages.slice(0, index + 1) (the message at index must be a user message,
otherwise a no-op) and streams a new reply, the same truncation semantics
edit() already has. Thread.tsx's onReload now maps the clicked message's
parentId through indexOfId to call regenerateFrom, falling back to
regenerate() when the id can't be resolved.

Covered by two new chat-controller unit tests and a new smoke step between
the edit step and the Stop step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… dialog

Spec 6.1 allows Save even when Test fails, so a mistyped base URL routinely
opens the non-dismissible model dialog with a failing list fetch, and the
header Settings button is inert behind the overlay — the only way out was
typing an arbitrary model id, which got persisted.

ModelDialog now takes a required onChangeConnection prop and always renders
a "Change connection" button; ChatApp wires it to open the settings dialog.
showModels already yields while settingsOpen is true, so the (dismissible,
since the step is "models") settings dialog appears, and cancelling it
returns to the model dialog.

Covered by a new smoke step after "changing the base URL asks for a model
again".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… this repo does not contain

Eight workspace:* references in five packages named webrun-wire packages, so httpeers
installed only inside an assembly and a standalone install failed with
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND. Each is now a caret range on the version already
published to npm, which equals the local webrun-wire checkout; inside an assembly
linkWorkspacePackages still links the local package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t included

The committed lockfile listed 7 of 15 importers. Regenerated with
pnpm install --lockfile-only in the member root; importers only added, none removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…en WebRTC is impossible

- runOnLimitedConnection on Libp2pLinkInit, ServeTransportInit, CreateRemoteInit
  and ServePeerInit, forwarded to connect/serveConnections
- reachHubRelayed(node, relayAddr, hubPeerId) dials <relay>/p2p-circuit/p2p/<hub>
  and returns the limited connection without closing it
- with the flag set, libp2pLink opens streams on the kept limited connection:
  libp2p 3.3.8 does not reuse a limited connection for a dial, and the
  conformance test measured a second circuit per call without this
- LimitedConnectionError maps to PeerLimitedConnectionError, kind
  "limited-connection" (502 at the edge)
- conformance: relay-fallback.test.ts over the in-process relay, with a
  relay-only member (no webRTC, no tcp) and a limited circuit asserted

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e opt-ins

- ServePeerInit: serveOnLimitedConnection (serving only) and
  callOnLimitedConnection (boolean | per-target predicate, outbound only,
  covering call and the router's forwarding); the combined flag is gone
- Libp2pLinkInit takes the same pair; CreateRemoteInit takes
  callOnLimitedConnection; ServeTransportInit keeps runOnLimitedConnection
  (serving). The kept-circuit-first stream selection applies only to targets
  the predicate allows
- reachHubRelayed documents that an existing unlimited connection is returned
  as is; error comments cite symbols, not line numbers
- conformance: the member calls the hub with a hub-only predicate; new cases
  prove its serving side stays closed to the circuit, with an opted-in control

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…de fails

startMember tries reachHub once per join; on any failure it keeps a relay
circuit to the hub (reachHubRelayed) and calls the hub over it. In relay mode
it skips reserveOnHub, the reservation supervisor and leaveRelay. The member
always calls with callOnLimitedConnection for its hub only; its serving side
stays closed to limited connections.

- createRouteEnsurer never dials the hub (hubRoute(hub, hub) cannot work, and
  on the fallback it was a doomed dial before every edge call to the hub).
- The keepalive takes a relinkHub; a relay-joined member re-links WebRTC
  first, then a new circuit, and a kept circuit counts as open. One re-link
  at a time.
- MemberHandle.hubLink() and SessionState.hubLink ("direct" | "relay", null
  unless live); HubLink exported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A relay-joined member's keepalive re-link called the WebRTC-first join path,
which could land it "direct" with no reservation, supervisor or wake watch.
Once a run is in relay mode, every re-link is now reachHubRelayed only, so
hubLink is fixed for the life of a handle (documented on HubLink/hubLink()).

Tests: the re-link case uses a WebRTC-capable member against a hub that
cannot upgrade and asserts no /webrtc dial on re-link; a relay-mode member
refuses a call the hub turns around over the circuit; a WebRTC-capable member
against such a hub joins in relay mode and keeps calling (join time, circuit
count and call times logged). The harness gains webRTCUpgrade and shares
hubHoldsCircuitTo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…service modules, a local door

apps/hub (@statewalker/httpeers-hub-daemon): startDaemon builds the hub the
conformance harness builds (circuit + WebRTC listen, membershipGater,
hubRelayService, relay reservation kept by superviseRelay), with the key in
hub.key, hub.env rewritten on every start, members and invitations in
fileStorage, rules.dl created from the mesh protocol defaults, and
revocations persisted to revocations.json and enforced on the hub's own
endpoints. It serves on limited connections so relay-fallback members reach
it. Service modules mount at /<id> on the mesh (caller "mesh") and at
/peers/<hubPeerId>/<id>/ on the local door (caller "local"), which never
goes through withAccess. The standalone lockfile gains the new importer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ations

- HUB_LOCAL_DOOR_HOST (default 0.0.0.0) is threaded to the door, which
  reports the address it bound; a host-networked hub sets 127.0.0.1.
- One createMonotonicClock is shared by createHub, mintToken and the
  revocation file, so a token cannot tie a revocation's timestamp.
- Daemon.revocationsFlushed() is the durability handle for a revoke.
- A malformed revocation entry refuses the start instead of being dropped.
- main.ts flushes revocations, then stops, within an 8 s deadline.
- A module's advertisement id must equal its mount id.
- Tests: the door's bind address, the config field, a malformed entry, the
  advertisement check, and a revoked member's token refused before and
  after a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements spec §5.4: one handler (`createAdminApi`) serving every
`/hub/api/*` route — mesh, roles, invitations, members, revoke, and its own
OpenAPI 3.1 document — mounted at `/hub` on the mesh behind `withAccess`
(capability `std:mesh.admin`) and served directly on the local door.

The `/hub/api` policy is appended to every hub's rules at load time, like a
module's own rules, so an existing `rules.dl` written before this task grants
it too, with nothing to migrate — skipped only when a rules file has no
`std:mesh.admin` concept at all, so it can't turn a valid custom rules file
into a hub that fails to start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two review fixes to Task 4's admin REST API:

- loadOrCreateRules now console.warn's when a rules.dl derives no
  std:mesh.admin capability, so the admin API silently going dark on the
  mesh (local door only) is no longer a silent event. Behaviour is
  unchanged -- still fail-closed, never fails the start.
- DELETE /hub/api/members/:peerId guards decodeURIComponent: a malformed
  percent-escape in the peerId now returns 400 with the API's usual JSON
  error shape instead of throwing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…path, curated OpenAPI, key minting

Adds the `llm` service module (spec §4.2, §5.1-§5.3, §5.6): a measured
passthrough to LiteLLM under `/peers/<hubPeerId>/llm` (path prefixing,
header hygiene, origin-agnostic Location/body rewriting for the sentinel
PROXY_BASE_URL, streamed bodies, upstream-unreachable mapping), a curated
`/llm/openapi.json`, `/llm/openapi.full.json` proxied to LiteLLM's own
document, and `/llm/keys` minting a scoped key with the master key never
exposed to a caller. `main.ts` registers it for `HUB_SERVICES=llm` and
refuses to start without both `HUB_LLM_UPSTREAM` and `LITELLM_MASTER_KEY`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sthrough

Important: /llm/keys sent the master key as Authorization, which the
appliance's litellm_key_header_name: x-litellm-api-key setting means real
LiteLLM ignores (spike Q3, measured) — it now sends
x-litellm-api-key: Bearer <masterKey> and never Authorization.

Hardening, from review:
- keys.ts forwards the caller's abort signal, never follows a redirect
  (the master key must not reach wherever a 3xx points), and maps a 2xx
  response with no string `key` to a 502 upstream-error.
- passthrough.ts drops content-encoding/content-length when present
  (undici decompresses gzip/br transparently but leaves both headers
  describing the encoded form); the unreachable/opaque-redirect marker
  cases get distinct kinds (upstream-unreachable vs upstream-redirect)
  and never name the internal upstream URL.
- rewriteLocation compares origins case-insensitively and requires a real
  boundary (/, ?, # or end of string) after the prefix, so one host is
  never mistaken for another that merely shares a numeric prefix.
- llmModule normalizes HUB_LLM_UPSTREAM once (strips trailing slashes) so
  passthrough and keys always agree on the upstream target.
- openapi.ts: /keys no longer declares the llmKey security scheme — it is
  authorized by the mesh's own app:llm.admin capability, not a caller
  x-litellm-api-key.
- modulesFor moved out of main.ts into modules.ts, and the entry-point
  guard added in review round 0 is removed: comparing import.meta.url to
  argv[1] silently skips main() under a symlinked entrypoint. main.ts now
  always runs main() when executed, with no guard to get wrong.
- Test-only: negative rule cases (near-miss resource strings) refused for
  a member.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A static page (`apps/hub/ui/`, plain DOM TypeScript + Vite, no
biscuit-wasm and no ServiceWorker) served by the local door at `/`,
porting the demos hub page's markup and behaviour onto `/hub/api/*`
instead of an in-tab `Hub` object: mesh id/relay/services, a mint-
invitation flow (roles select, link, QR via `@statewalker/httpeers-qr`),
a polled members table with revoke, and the `LiteLLM dashboard` link.

- The admin API's contract is unchanged. Direct-WebRTC vs relay is
  derived PER MEMBER from `GET /hub/api/members`'s existing `addrs`
  (`/webrtc` vs `/p2p-circuit`), shown as a table column rather than one
  hub-wide fact, since the mesh can genuinely mix both at once.
- The QR encodes the invitation LINK, not the blob (a deliberate
  deviation from the demos page, which encodes the blob because a
  tab-hosted hub has no join page for a bare invitation id to resolve
  against — this hub has a real one, and the link already carries it).
- `local-door.ts` gains `createStaticUiHandler(root)`: `/` -> index.html,
  content types by extension, 404 for anything missing, and a boundary
  check after `decodeURIComponent` so a percent-encoded `..` cannot walk
  out of `dist-ui/` (a literal `..` is already collapsed by `new URL`
  before the handler runs; the percent-encoded form is not, which is
  what the check is actually for). Wired as the local door's `ui`
  fallback, so `daemon.ts` needed no changes.
- `test:ui` (not part of `test`): a Playwright script against a real
  in-process daemon and loopback relay — mints an invitation, sees the
  link and QR, sees the members table, checks the dashboard link's
  href. New unit tests cover the static server directly (content types,
  404, both traversal shapes).
- `build` now also runs the new `build:ui`; `typecheck`/`lint` cover
  `ui/` too. New deps: `@statewalker/httpeers-qr` (workspace:*, not
  webrun-wire), `playwright`/`vite` (devDependencies, versions already
  used by apps/demos and apps/llm-chat). Lockfile updated via the
  assembly's own pnpm install; no other package touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in PR #2 (httpeers-access evaluates Biscuit with
@statewalker/webrun-biscuit ^0.2.1; the demos lose the wasm loader and
aliases) and upstream's standalone-install fixes (f562bb5, fdaf536,
6915a12), which make the same dependency changes as this branch's
29e758c/53cf92b/976d2f0.

Only pnpm-lock.yaml conflicted. It was not hand-merged: upstream's side was
taken and regenerated with `pnpm install --lockfile-only` in the member
workspace. Its importers are the union of both parents (upstream's 14 plus
apps/hub and apps/llm-chat); no overrides, no link outside the repo.

package.json files and pnpm-workspace.yaml merged cleanly: both sides had
made identical edits apart from httpeers-access's, which only upstream
touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
After the merge of native TypeScript Biscuit, three comments in apps/hub still
described the wasm engine: the LLM module's note on where its policies were
checked to parse, and the admin UI's two notes on why it needs no wasm alias.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aefik in one compose file

Task 7: apps/hub/Dockerfile (multi-stage, pnpm deploy --legacy
--config.node-linker=hoisted so node-datachannel resolves at the image
root) and deploy/llm-appliance/ (compose.yml, compose.test.yml,
compose.host.yml, litellm-entrypoint.sh + config.yaml, Traefik's
file-provider dynamic config, scripts/health.sh, a fake-LLM smoke test).
Verified end to end: image builds, the full stack comes up healthy,
scripts/health.sh all PASS, a minted key streams a chat completion
through Traefik -> hub -> LiteLLM -> fake-llm ending in [DONE], and a
hub restart keeps the same hubPeerId with LiteLLM still serving under
the same root path.

A root .dockerignore (**/node_modules/ etc.) is required, not cosmetic:
this repo is checked out nested inside a multirepo assembly, so its own
node_modules holds symlinks computed relative to the assembly root:
copying them into the image (a bare `node_modules/` dockerignore
pattern only matches at the context root, not every depth) breaks
packages/httpeers-qr's build with an unresolvable module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d reuses the chat

- The key header is configurable: ChatConfig/EndpointConfig.apiKeyHeader (default
  authorization), value always "Bearer <key>"; applyEndpoint keeps it, ModelDialog and
  SettingsDialog's Test pass it.
- mesh.html -> src/pages/mesh.tsx: createSession with edge key "peers", join from ?join= or a
  pasted invitation, find the llm openapi-service advert, discoverLlm reads openapi.json
  (relative server -> baseUrl, llmKey -> header, x-httpeers-entry -> dashboard), store the
  "mesh" config keeping a stored key, a Key step (Use key / Request a key with the 403
  message), then ChatApp with the link mode and an admin-only LiteLLM dashboard link.
- Vite multi-page build; scripts/copy-assets.mjs copies sw.js; no Biscuit wasm.
- The boundary walk follows dynamic import("./x.js"); only pages/mesh.tsx reaches httpeers.
- scripts/mesh-smoke.mjs drives an admin and a member against a running appliance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st; ship only runtime files

Critical: compose.host.yml's Traefik still listened on the base file's
bare :8080, which is safe only under bridge networking (where Docker's
127.0.0.1:8080:8080 publish does the restricting). Under
network_mode: host there is no publish step, so Traefik bound the
basic-auth-only admin door to every interface on the host. Fixed by
overriding the entrypoint address to 127.0.0.1:8080 in that variant.
Verified the override renders correctly on the actually-installed
Compose (v2.3.3) via `docker compose config`; also documented (in the
file's own comments and a new README warning) that the same old
Compose silently fails to clear networks:/ports: via !reset, so
`up`-ing this variant needs a newer Compose or a hand-edited base file
— not exercised beyond `config`, per instruction.

Minor: apps/hub/package.json now declares files: [dist, dist-ui], so
`pnpm deploy --prod` no longer ships src/, tests/, ui/ or tsconfig.*
into the runtime image. Rebuilt and confirmed `docker run ... ls /app`
lists only dist/dist-ui/node_modules/package.json and node-datachannel
still loads; did not recreate the running hub container since the
standalone image check already verified it, and re-ran
scripts/health.sh against the still-running (previous-image) stack to
confirm nothing regressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Discovery targets handle.hubPeerId only; an llm advert from any other peer is ignored.
- discoverLlm refuses a service base, OpenAI base or dashboard URL outside
  <edge><hubPeerId>/llm/ (absolute, protocol-relative, ../ or encoded escapes, another
  peer, a query), so a hostile document cannot receive the key.
- meshConfig keeps a stored key only for the same endpoint.
- A superseded discovery run (old handle, repeated Try again) writes no config and no
  stage; a key arriving for a superseded run is dropped.
- The dashboard link is rel="noopener noreferrer".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mkotelnikov and others added 28 commits September 18, 2026 13:48
…oin widget

Each page's #join-form and #session-controls markup, their render()
branches and their three listeners are replaced by one #mesh-join
section the widget mounts into, fed from render(). The pages gain QR
scanning (camera or picture) and "Leave this mesh". render() keeps its
name and its own fields (#state, #peer-id, the mesh view). The Playwright
smokes fill .hp-join-input and press .hp-join-submit instead of #invite
and #join.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…base) to take minutes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the public Actions log

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…te Postgres

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…B_CONTAINER=remote)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on the demos hub's own invitation QR: html5-qrcode's scanFile
found nothing in a clean 684 px screenshot, nor at 600 or 480 px, and
read it only at 400 px and below -- smaller than any phone screenshot.
On "no-qr" the default scanner now draws the picture at its own size
(capped at 1600), 800 and 400 px and runs decodeQr (jsQR, loaded
lazily). Every size from 300 to 3000 px now decodes, including a
blurred, tilted 3000 px copy, and scanning the hub page's real QR joins
its mesh live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd the demos

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ut, admin tunnel, measured E2E

The README gains 'On the httpeers.net server' (what runs where, the forced-command
deploy and why, the one-time setup, rollback); the e2e script and README gain
the remote-hub mode and model-agnostic reply completion; RESULTS.md records the
all-PASS run against the server; the root README's layout lists the apps and the
appliance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pipeline was exercised end to end from this branch (runs 35341105672 to
the merge deploy); from here on only main deploys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… write refilled

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pure and fetch-injected: the admin check (GET /hub/api/roles, 403/404 final,
a hiccup retried), the mesh-view hint that decides whether to ask at all, the
member/admin roles offered, the 1 h / 1 d / 7 d lifetimes and the request body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member or admin (with a warning), expiry, then the link, its QR code, Share
(navigator.share) and Copy; pending invitations with role and time left, never
their ids. Shown only while live and once the hub's roles call succeeds; in the
compact widget it sits in the Mesh menu. invite: false leaves it out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anchored to its button, the menu -- now tall with the Invite panel -- ran off
the left edge of a 390 px screen when the button sat mid-header. Below 30rem it
is fixed to the bottom, 70vh at most and scrolling, leaving the header and the
toggle that closes it in view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… usage

Who sees it and why (live, the view's admin hint, then GET /hub/api/roles),
what it sends, which roles it offers (member and admin; the hub's hidden role
is left to the door), how the secret is kept off logs and the screen, and how
an admin invites from mesh.html without a tunnel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s it cannot use

libp2p's relay server holds at most 15 reservations, for two hours each, and
keeps one after its holder disconnects. A hub on those defaults filled after
fifteen distinct peers and then refused every member with RESERVATION_REFUSED,
which a member reports as a failed join.

- hubRelayService sizes the store at HUB_MAX_RESERVATIONS (4096), overridable
  with { maxReservations }; the per-circuit data limits are unchanged.
- It releases a reservation when its holder's last connection closes.
- releaseReservation(relay, peer) frees a revoked member's slot at once.

tests/hub-relay.test.ts reproduces the refusal (5 of 20 connected members
refused before the fix) and pins the release on disconnect and on revocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
libp2p reports every failed reservation as 'Some configured addresses failed
to be listened on', with the relay's status only in the text. reserveOnHub
replaced that with one blanket message naming PERMISSION_DENIED and
UnsupportedProtocolError whatever happened, so a hub whose reservation store
was full (RESERVATION_REFUSED) read in production as 'this peer is not a
member'.

It now throws a HubReservationError carrying the relay status and a refusal
kind (store-full, resource-limit, not-a-member, no-relay, no-answer, other),
with a message that says which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot a peer id

A revocation must not fail half-way because the subject cannot be parsed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lay slot

- loadConfig reads HUB_MAX_RESERVATIONS (positive integer, optional; unset
  keeps hubRelayService's HUB_MAX_RESERVATIONS, 4096) into createHubNode.
- createHubNode returns its typed services, so the daemon can reach the relay.
- The admin revoke calls releaseReservation after removing and revoking.

daemon.test.ts pins it end to end: with one slot, a member joins, is revoked
through the door while still connected, and a second member still joins
(RESERVATION_REFUSED without the release).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e data limits

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…optional)

Empty is unset, so the hub keeps its default (4096) unless .env sets it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r's refusal

mesh-harness takes maxRelayReservations, so a hub's relay store can be filled
by a real member. member-reservation-fallback.test.ts: with one slot taken,
the next page-shaped member must still join -- in relay mode, with the hub's
RESERVATION_REFUSED as its note, one kept circuit on both ends, no reservation
supervisor left running -- and a non-member's reserveOnHub must read
PERMISSION_DENIED. Fails on llm-appliance exactly as production did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ad of failing it

After a direct (WebRTC) link and a successful resume/redeem, a hub whose relay
store was full refused the reservation and startMember failed the whole join,
while a member that had fallen back at the upgrade worked. Now a capacity
refusal (RESERVATION_REFUSED, RESOURCE_LIMIT_EXCEEDED) or no answer at all
hangs up the WebRTC link, opens the kept relay circuit and continues in relay
mode: no supervisor, no leaveRelay, the relay-mode re-link -- the same state
as the upgrade fallback. PERMISSION_DENIED, a hub with no relay service and
any other status still fail the join (fallsBackToRelay).

MemberHandle.hubLinkNote says why; the session shows it as the live phase's
note, so the join widget reads 'Connected (relay)' with the reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ow reports

Member README gains a 'Direct or relay' section; libp2p README lists
reachHubRelayed and HubReservationError and explains the refusal kinds;
conformance README names the relay-mode suites; the appliance troubleshooting
entry gains the full-store cause. Test counts brought up to date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mbers, released on disconnect and revoke

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a member to relay mode

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	packages/httpeers-libp2p/README.md
@mkotelnikov
mkotelnikov merged commit 406ce09 into main Sep 18, 2026
3 checks passed
@mkotelnikov
mkotelnikov deleted the llm-appliance branch September 20, 2026 07:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant