Compare commits

..

4 commits

Author SHA1 Message Date
thibaud-leclere
6dd08e5a88 chore: trim repo for coolify aio 2026-05-06 09:57:24 +02:00
thibaud-leclere
3de3a14d06 docs: document coolify aio cleanup 2026-05-06 09:50:56 +02:00
thibaud-leclere
d01a384832 feat: require login for web app 2026-05-06 09:22:55 +02:00
thibaud-leclere
dacca46fbe docs: specify required web login 2026-05-06 09:09:57 +02:00
501 changed files with 833 additions and 70722 deletions

View file

@ -1,35 +1,46 @@
.devenv*
.direnv
.devcontainer
.git .git
.github .github
.husky .husky
.vscode .vscode
.idea
.devcontainer
.envrc .envrc
devenv.yaml .devenv*
devenv.nix .direnv
.prettierrc.js devenv.local.nix
.prettierignore
.editorconfig
.npmrc
.firebaserc
node_modules node_modules
**/node_modules **/node_modules
**/*/node_modules .pnpm-store
**/dist **/dist
**/build **/build
**/target **/target
**/coverage
**/.cache
**/.parcel-cache
**/.svelte-kit
**/.nuxt
**/__tests__ **/__tests__
**/*.test.* **/*.test.*
**/coverage **/*.spec.*
tests/*/screenshots
tests/*/videos
docs
*.md *.md
!README.md
LICENSE LICENSE
CODEOWNERS CODEOWNERS
.DS_Store .firebase
.firebaserc
firebase.json
firestore.indexes.json
firestore.rules
netlify.toml
*.log *.log
.DS_Store

View file

@ -1,5 +0,0 @@
{
"projects": {
"default": "postwoman-api"
}
}

View file

@ -1,21 +0,0 @@
# CODEOWNERS is prioritized from bottom to top
# Packages
/packages/codemirror-lang-graphql/ @AndrewBastin
/packages/hoppscotch-cli/ @jamesgeorge007
/packages/hoppscotch-data/ @AndrewBastin
/packages/hoppscotch-js-sandbox/ @jamesgeorge007
/packages/hoppscotch-selfhost-web/ @jamesgeorge007
/packages/hoppscotch-selfhost-desktop/ @AndrewBastin
/packages/hoppscotch-sh-admin/ @JoelJacobStephen
/packages/hoppscotch-backend/ @balub
# READMEs and other documentation files
*.md @liyasthomas
# Self Host deployment related files
*.Dockerfile @balub
docker-compose.yml @balub
docker-compose.deploy.yml @balub
*.Caddyfile @balub
.dockerignore @balub

View file

@ -1,132 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
support@hoppscotch.io.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

View file

@ -1,14 +0,0 @@
# Contributing
When contributing to this repository, please first discuss the change you wish to make via issue,
email, or any other method with the owners of this repository before making a change.
Please note we have a code of conduct, please follow it in all your interactions with the project.
## Pull Request Process
1. Ensure any install or build dependencies are removed before the end of the layer when doing a
build.
2. Update the README.md with details of changes to the interface, this includes new environment
variables, exposed ports, useful file locations and container parameters.
3. Make sure you do not expose environment variables or other sensitive information in your PR.

View file

@ -1,21 +1,20 @@
COMPOSE := docker compose COMPOSE := docker compose
PROFILE := default
ENV_FILE := .env ENV_FILE := .env
ENV_EXAMPLE := .env.example ENV_EXAMPLE := .env.example
.PHONY: up down logs ps ensure-env .PHONY: up down logs ps ensure-env
up: ensure-env up: ensure-env
$(COMPOSE) --profile $(PROFILE) up -d --build $(COMPOSE) up -d --build
down: down:
$(COMPOSE) --profile $(PROFILE) down $(COMPOSE) down
logs: logs:
$(COMPOSE) --profile $(PROFILE) logs -f $(COMPOSE) logs -f
ps: ps:
$(COMPOSE) --profile $(PROFILE) ps $(COMPOSE) ps
ensure-env: ensure-env:
@test -f $(ENV_FILE) || cp $(ENV_EXAMPLE) $(ENV_FILE) @test -f $(ENV_FILE) || cp $(ENV_EXAMPLE) $(ENV_FILE)

335
README.md
View file

@ -1,299 +1,78 @@
<div align="center"> # Hoppscotch AIO
<a href="https://hoppscotch.io">
<img
src="https://avatars.githubusercontent.com/u/56705483"
alt="Hoppscotch"
height="64"
/>
</a>
<h3>
<b>
Hoppscotch
</b>
</h3>
<b>
Open Source API Development Ecosystem
</b>
<p>
[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen?logo=github)](CODE_OF_CONDUCT.md) [![Website](https://img.shields.io/website?url=https%3A%2F%2Fhoppscotch.io&logo=hoppscotch)](https://hoppscotch.io) [![Tests](https://github.com/hoppscotch/hoppscotch/actions/workflows/tests.yml/badge.svg)](https://github.com/hoppscotch/hoppscotch/actions) [![Tweet](https://img.shields.io/twitter/url?url=https%3A%2F%2Fhoppscotch.io%2F)](https://x.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io) Fork interne de Hoppscotch réduit au déploiement self-host all-in-one pour
Coolify.
</p> ## Contenu conservé
<p>
<sub>
Built with ❤︎ by
<a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors">
contributors
</a>
</sub>
</p>
<br />
<p>
<a href="https://hoppscotch.io">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/hoppscotch-common/public/images/banner-dark.png">
<source media="(prefers-color-scheme: light)" srcset="./packages/hoppscotch-common/public/images/banner-light.png">
<img alt="Hoppscotch" src="./packages/hoppscotch-common/public/images/banner-dark.png">
</picture>
</a>
</p>
</div>
_We highly recommend you take a look at the [**Hoppscotch Documentation**](https://docs.hoppscotch.io) to learn more about the app._ - Application web self-host : `packages/hoppscotch-selfhost-web`
- Backend NestJS et Prisma : `packages/hoppscotch-backend`
- Admin self-host : `packages/hoppscotch-sh-admin`
- Packages partagés requis au build : `hoppscotch-common`, `hoppscotch-data`,
`hoppscotch-kernel`, `hoppscotch-js-sandbox`, `codemirror-lang-graphql`
- Image Docker de production : `prod.Dockerfile`
- Déploiement Compose AIO : `docker-compose.yml`
#### **Support** ## Déploiement Coolify
[![Chat on Discord](https://img.shields.io/badge/chat-Discord-7289DA?logo=discord)](https://hoppscotch.io/discord) [![Chat on Telegram](https://img.shields.io/badge/chat-Telegram-2CA5E0?logo=telegram)](https://hoppscotch.io/telegram) [![Discuss on GitHub](https://img.shields.io/badge/discussions-GitHub-333333?logo=github)](https://github.com/hoppscotch/hoppscotch/discussions) Utiliser `docker-compose.yml` comme source Compose.
### **Features** Services créés :
❤️ **Lightweight:** Crafted with minimalistic UI design. - `hoppscotch-aio` : image AIO construite depuis `prod.Dockerfile`, target
`aio`
- `hoppscotch-db` : PostgreSQL 15 avec volume persistant
⚡️ **Fast:** Send requests and get responses in real time. Ports exposés :
🗄️ **HTTP Methods:** Request methods define the type of action you are requesting to be performed. - `3080` -> Caddy AIO HTTP
- `3000` -> app web
- `3100` -> admin
- `3170` -> backend
- `3200` -> serveur de bundles webapp
- `GET` - Requests retrieve resource information Variables minimales à vérifier dans Coolify :
- `POST` - The server creates a new entry in a database
- `PUT` - Updates an existing resource
- `PATCH` - Very similar to `PUT` but makes a partial update on a resource
- `DELETE` - Deletes resource or related component
- `HEAD` - Retrieve response headers identical to those of a GET request, but without the response body.
- `CONNECT` - Establishes a tunnel to the server identified by the target resource
- `OPTIONS` - Describe the communication options for the target resource
- `TRACE` - Performs a message loop-back test along the path to the target resource
- `<custom>` - Some APIs use custom request methods such as `LIST`. Type in your custom methods.
🌈 **Theming:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings). ```env
POSTGRES_PASSWORD=<mot-de-passe-fort>
POSTGRES_DB=hoppscotch
DATA_ENCRYPTION_KEY=<chaine-de-32-caracteres>
VITE_BASE_URL=https://<domaine-app>
VITE_SHORTCODE_BASE_URL=https://<domaine-app>
VITE_ADMIN_URL=https://<domaine-admin>
VITE_BACKEND_GQL_URL=https://<domaine-backend>/graphql
VITE_BACKEND_WS_URL=wss://<domaine-backend>/graphql
VITE_BACKEND_API_URL=https://<domaine-backend>/v1
WHITELISTED_ORIGINS=https://<domaine-app>,https://<domaine-admin>
TRUST_PROXY=true
```
- Choose a theme: System preference, Light, Dark, and Black `DATABASE_URL` est généré par `docker-compose.yml` pour la base PostgreSQL
- Choose accent colors: Green, Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink incluse. Si le déploiement passe plus tard sur une base externe, adapter
- Distraction-free Zen mode explicitement le service `hoppscotch-aio`.
_Customized themes are synced with your cloud/local session._ ## Local
🔥 **PWA:** Install as a [Progressive Web App](https://web.dev/progressive-web-apps) on your device. Créer le fichier `.env` si nécessaire :
- Instant loading with Service Workers ```sh
- Offline support cp .env.example .env
- Low RAM/memory and CPU usage ```
- Add to Home Screen
- Desktop PWA
🚀 **Request:** Retrieve response from endpoint instantly. Démarrer l'AIO local :
1. Choose `method` ```sh
2. Enter `URL` docker compose up -d --build
3. Send ```
- Copy/share public "Share URL" Voir les logs :
- Generate/copy request code snippets for 10+ languages and frameworks
- Import `cURL`
- Label requests
🔌 **WebSocket:** Establish full-duplex communication channels over a single TCP connection. ```sh
docker compose logs -f
```
📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling. Arrêter :
🌩 **Socket.IO:** Send and Receive data with the SocketIO server. ```sh
docker compose down
🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker. ```
🔮 **GraphQL:** GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data.
- Set endpoint and get schema
- Multi-column docs
- Set custom request headers
- Query schema
- Get query response
🔐 **Authorization:** Allows to identify the end-user.
- None
- Basic
- Bearer Token
- OAuth 2.0
- OIDC Access Token/PKCE
📢 **Headers:** Describes the format the body of your request is being sent in.
📫 **Parameters:** Use request parameters to set varying parts in simulated requests.
📃 **Request Body:** Used to send and receive data via the REST API.
- Set `Content Type`
- FormData, JSON, and many more
- Toggle between key-value and RAW input parameter list
📮 **Response:** Contains the status line, headers, and the message/response body.
- Copy the response to the clipboard
- Download the response as a file
- View response headers
- View raw and preview HTML, image, JSON, and XML responses
**History:** Request entries are synced with your cloud/local session storage.
📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click.
- Unlimited collections, folders, and requests
- Nested folders
- Export and import as a file or GitHub gist
_Collections are synced with your cloud/local session storage._
📜 **Pre-Request Scripts:** Snippets of code associated with a request that is executed before the request is sent.
- Set environment variables
- Include timestamp in the request headers
- Send a random alphanumeric string in the URL parameters
- Any JavaScript functions
👨‍👩‍👧‍👦 **Teams:** Helps you collaborate across your teams to design, develop, and test APIs faster.
- Create unlimited teams
- Create unlimited shared collections
- Create unlimited team members
- Role-based access control
- Cloud sync
- Multiple devices
👥 **Workspaces:** Organize your personal and team collections environments into workspaces. Easily switch between workspaces to manage multiple projects.
- Create unlimited workspaces
- Switch between personal and team workspaces
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs.
- Hide your IP address
- Fixes [`CORS`](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) (Cross-Origin Resource Sharing) issues
- Access APIs served in non-HTTPS (`http://`) endpoints
- Use your Proxy URL
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**._
🌎 **i18n:** Experience the app in your language.
Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md) and the process for submitting pull requests to us.
☁️ **Auth + Sync:** Sign in and sync your data in real-time across all your devices.
**Sign in with:**
- GitHub
- Google
- Microsoft
- Email
- SSO (Single Sign-On)[^EE]
**🔄 Synchronize your data:** Handoff to continue tasks on your other devices.
- Workspaces
- History
- Collections
- Environments
- Settings
**Post-Request Tests:** Write tests associated with a request that is executed after the request's response.
- Check the status code as an integer
- Filter response headers
- Parse the response data
- Set environment variables
- Write JavaScript code
🌱 **Environments:** Environment variables allow you to store and reuse values in your requests and scripts.
- Unlimited environments and variables
- Initialize through the pre-request script
- Export as / import from GitHub gist
<details>
<summary><i>Use-cases</i></summary>
---
- By storing a value in a variable, you can reference it throughout your request section
- If you need to update the value, you only have to change it in one place
- Using variables increases your ability to work efficiently and minimizes the likelihood of error
---
</details>
🚚 **Bulk Edit:** Edit key-value pairs in bulk.
- Entries are separated by newline
- Keys and values are separated by `:`
- Prepend `#` to any row you want to add but keep disabled
🎛️ **Admin dashboard:** Manage your team and invite members.
- Insights
- Manage users
- Manage teams
📦 **Add-ons:** Official add-ons for hoppscotch.
- **[Hoppscotch CLI](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-cli)** - Command-line interface for Hoppscotch.
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch.
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that enhance your Hoppscotch experience.
[![Firefox](https://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_16x16.png) **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) &nbsp;|&nbsp; [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_16x16.png) **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
> **Extensions fix `CORS` issues.**
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
**For a complete list of features, please read our [documentation](https://docs.hoppscotch.io).**
## **Demo**
- Web : [hoppscotch.io](https://hoppscotch.io)
- Windows/Linux/macOS : [Desktop Apps](https://docs.hoppscotch.io/documentation/clients/desktop#download-hoppscotch-desktop-app)
## Usage
1. Provide your API endpoint in the URL field
2. Click "Send" to simulate the request
3. View the response
## Developing
Follow our [self-hosting documentation](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
## Contributing
Please contribute using [GitHub Flow](https://guides.github.com/introduction/flow). Create a branch, add commits, and [open a pull request](https://github.com/hoppscotch/hoppscotch/compare).
Please read [`CONTRIBUTING`](CONTRIBUTING.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md), and the process for submitting pull requests to us.
## Continuous Integration
We use [GitHub Actions](https://github.com/features/actions) for continuous integration. Check out our [build workflows](https://github.com/hoppscotch/hoppscotch/actions).
## Changelog
See the [`CHANGELOG`](CHANGELOG.md) file for details.
## Authors
This project owes its existence to the collective efforts of all those who contribute — [contribute now](CONTRIBUTING.md).
<div align="center">
<a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors">
<img src="https://opencollective.com/hoppscotch/contributors.svg?width=840&button=false"
alt="Contributors"
width="100%" />
</a>
</div>
## License
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) — see the [`LICENSE`](LICENSE) file for details.
[^EE]: Enterprise edition feature. [Learn more](https://docs.hoppscotch.io/documentation/self-host/getting-started).

View file

@ -1,135 +0,0 @@
# Security Policy
- [Security Policy](#security-policy)
- [Scope](#scope)
- [Architecture and threat model](#architecture-and-threat-model)
- [Desktop app](#desktop-app)
- [Hoppscotch Agent](#hoppscotch-agent)
- [Self-hosted instances](#self-hosted-instances)
- [Security controls](#security-controls)
- [Reporting a security vulnerability](#reporting-a-security-vulnerability)
- [What does not qualify as a vulnerability](#what-does-not-qualify-as-a-vulnerability)
## Scope
This policy covers components in the [hoppscotch/hoppscotch](https://github.com/hoppscotch/hoppscotch) repository:
- **Desktop app**: the Tauri-based desktop client, including standalone use and connections to self-hosted or cloud-hosted instances.
- **Hoppscotch Agent**: the local relay service that runs on the user's machine and proxies requests from the web client.
- **Hoppscotch CLI**: the command-line client for running collections and tests, against either local collection files or an instance.
- **Self-hosted backend**: the Node.js backend, PostgreSQL data layer, and associated services deployed by self-hosting organisations.
- **Self-hosted web client and admin panel**: the web frontend and admin dashboard served by a self-hosted instance.
**Out of scope** (separate security boundaries):
- The cloud-hosted platform at hoppscotch.io or the website at hoppscotch.com.
- If you find a vulnerability that spans both the cloud platform and a component covered here, report it; we will coordinate triage across boundaries.
- The Hoppscotch browser extension (separate repository and distribution channel).
- Third-party proxies or community forks.
## Architecture and threat model
Hoppscotch is a client-side API development and testing tool. The threat model differs by deployment mode.
### Desktop app
**The user is the operator.** The person sending API requests is the same person who configured the tool and entered their credentials. This is fundamentally different from a multi-tenant web service where untrusted users submit input to a shared backend.
**Local data storage is by design.** In standalone mode, the Desktop app persists collections, environments, request history, and credentials (including tokens, API keys, and other secrets) in local storage. This data is protected by OS-level access controls and, where enabled, full-disk encryption (FileVault, BitLocker, LUKS). When connected to a self-hosted or cloud backend, data syncs to the server while a local copy is retained (see the [self-hosted section](#self-hosted-instances)).
**Secret environment variables are stored locally and never synced to the server.** Environment variables marked as secret are kept in the client's local store (the Desktop app's data store or the browser's local storage, depending on platform) and are excluded from server sync. They follow the same local-data security posture as other credentials on that platform.
**The relay sends HTTP requests to arbitrary URLs provided by the user.** This includes localhost, private IP ranges, and cloud metadata endpoints. The relay runs on the user's machine, and the user controls what URLs it reaches. Separately, the Desktop app's realtime features (including WebSocket, SSE, Socket.IO, and MQTT) can also connect to user-specified endpoints under the same trust model: the user initiates and controls the connection.
**Per-domain TLS configuration is user-controlled.** The relay supports custom CA certificates, client certificates (PEM and PKCS#12), and per-domain toggles for host and peer verification. Users can disable TLS verification for specific domains to work with self-signed certificates or corporate PKI environments. These are deliberate operator choices on the user's own machine.
**The Desktop app loads web application bundles from instances the user adds.** When a user adds a self-hosted instance, the app downloads the instance's compiled web application (HTML, JavaScript, CSS) and runs it in an embedded webview. Remote bundles are verified with Ed25519 signatures and per-file BLAKE3 integrity hashes before loading (see [Security controls](#security-controls)). Bundles shipped with the installer are trusted as part of the build and release signing process. Adding an instance is an explicit trust decision, comparable to installing an extension or connecting to a self-hosted service.
**Debug-level logging is intentional.** The Desktop app logs at debug level by default and writes to rotating local log files. The log files sit alongside the same data in the application's own data store.
**Auto-updates are signature-verified.** The Desktop app checks `releases.hoppscotch.com` for available updates. Update manifests are verified against a public key before any binary is applied. This is a read-only check; no user data, credentials, or usage information is transmitted.
**Local backups are created on version changes.** The Desktop app creates backups of the local data store when the application version changes, retaining up to three backups. These backups follow the same security posture as the primary data store: local files protected by OS access controls.
### Hoppscotch Agent
The Agent is a standalone local service that acts as an HTTP relay for the Hoppscotch web client, providing capabilities the browser sandbox restricts (custom headers, localhost access, client certificates, CORS bypass).
**The Agent runs on the user's machine and listens on localhost.** It binds to port 9119 with a permissive CORS policy, meaning any origin can reach the port at the network level. Access control is enforced at the application layer through a registration handshake: the user enters a 6-digit one-time password displayed in the Agent UI, which establishes an encrypted communication channel (AES-256-GCM with X25519 key exchange). After registration, subsequent requests are authenticated and encrypted. The OTP does not expire and registration attempts are not rate-limited; the security assumption is that the user initiates registration intentionally while the Agent UI is visible.
**The same relay trust model applies.** The Agent sends requests to arbitrary user-specified URLs, including private IP ranges and localhost. The user controls what URLs it reaches and what TLS, proxy, and certificate configuration applies per domain.
**Agent data is stored locally.** Registration keys, per-domain settings (proxy configuration, client certificates, CA certificates, TLS verification toggles), and logs follow the same local-data security posture as the Desktop app.
### Self-hosted instances
With the backend deployed, the security model changes:
**The instance administrator is the operator; users are tenants.** Self-hosted instances support multiple users, teams, role-based access control, and shared collections. Authentication and authorisation boundaries must hold between users, and server-side data must be protected at rest and in transit.
**Data is stored server-side and locally.** Collections, environments, request history, and team data are persisted in PostgreSQL. Desktop app users connected to a self-hosted backend also retain a local copy; the local copy follows the same posture described in the [Desktop app section](#desktop-app). Credentials in shared team collections are accessible to team members with appropriate roles. The self-hosting organisation is responsible for database encryption, backup security, and access controls.
**Collections can be published via public URLs.** Self-hosted instances allow publishing collections as documentation accessible via UUID-based slugs. Published documentation is publicly accessible without authentication. The self-hosting organisation controls which collections are published.
**The admin dashboard has elevated privileges.** Instance administrators can view and manage all users, send invitations, and configure instance-wide settings through the admin interface. Admin actions are subject to role checks but operate across all teams and users on the instance.
**Infrastructure API tokens provide programmatic access.** The backend supports API tokens (infra tokens) with configurable expiry for programmatic access to instance management. These tokens should be treated with the same care as admin credentials.
**Backend session management.** User sessions use configurable cookie names and auto-generated session secrets. The self-hosting organisation can override session configuration via environment variables. Session secrets must be set explicitly in production deployments; auto-generated values are not suitable for production use.
**Optional analytics.** If `INFRA.ALLOW_ANALYTICS_COLLECTION` is enabled, the backend sends aggregate instance telemetry (user count, workspace count, version) to PostHog. Opt-in, disabled by default. No request content, credentials, or per-user data is included.
## Security controls
**Bundle signature verification.** Remote bundles from self-hosted instances are verified with Ed25519 signatures and per-file BLAKE3 hashes. A bundle with an invalid signature or hash mismatch is rejected and will not load. The signing key is fetched from the serving instance over the instance connection (TLS/HTTPS strongly recommended). Signature verification protects against bundle corruption in the local cache and against tampering in transit when the connection is trusted. It does not protect against a compromised instance, since the instance provides both the key and the bundle, nor against an active man-in-the-middle if the key is fetched over untrusted transport, since an attacker could replace both. The trust boundary is the connection to the instance and the user's decision to add it.
**Script sandboxing.** Pre-request and post-request scripts are isolated from the host environment. By default, scripts run in a QuickJS WebAssembly sandbox on every platform — isolated from the browser context, the Tauri IPC layer (on Desktop), and the host OS. The opt-out mechanism for the legacy compatibility mode differs per platform: Desktop and web expose the "Experimental scripting sandbox" toggle in Settings (on by default); the CLI opts in via the `--legacy-sandbox` flag. The legacy compatibility mode is retained as a backward-compatibility path for scripts that rely on host JavaScript semantics not exposed under QuickJS — on Desktop and web it runs scripts in a dedicated Web Worker using the `Function` constructor; on the CLI it runs scripts in an `isolated-vm` V8 isolate. The Web Worker legacy path does not provide the same isolation guarantees as QuickJS; the `isolated-vm` legacy path provides V8-isolate-level isolation but a different API surface from the QuickJS path. In the QuickJS paths, scripts receive controlled access to request data via the `pw`, `hopp`, and `pm` API namespaces, with request mutation limited to the documented pre-request APIs; network access is mediated through a controlled fetch hook, and scripts cannot make arbitrary system calls or access the filesystem. The Web Worker legacy mode preserves a separate Web Worker execution context but exposes only the `pw` namespace and does not mediate access to standard worker globals such as `fetch`; users opting in accept that scripts can reach any URL the worker context can reach. Scripts imported from external collection files follow the same default-versus-legacy execution path and constraints as locally authored scripts.
**Update signature verification.** The auto-updater verifies update manifests against a public key before applying any update. A tampered manifest or binary will be rejected.
**Rate limiting.** The self-hosted backend enforces request rate limiting via configurable TTL and max-request thresholds (`INFRA.RATE_LIMIT_TTL`, `INFRA.RATE_LIMIT_MAX`). This applies to REST and GraphQL endpoints by default, though some authenticated mutations opt out of throttling where rate limiting would interfere with normal interactive use.
**GraphQL query complexity limiting.** The self-hosted backend enforces query complexity limits on the GraphQL API to prevent denial-of-service through deeply nested or expensive queries.
## Reporting a security vulnerability
We use [GitHub Security Advisories](https://github.com/hoppscotch/hoppscotch/security/advisories) to manage reports. If you do not receive a response, reach out to support@hoppscotch.io with the GHSA advisory link.
If you disagree with our assessment, reply on the advisory with additional context or evidence. We will re-evaluate.
Reports must demonstrate familiarity with the architecture and threat model described in this document. A report that flags a behaviour already documented here as intentional, or that applies a generic vulnerability classification (such as SSRF, insecure storage, or CORS misconfiguration) without explaining how the finding circumvents the stated trust model, will be closed. This applies to all reports regardless of how they were produced, including those generated with AI tools, LLMs, or automated scanners.
> [!NOTE]
> Advisories may move to the relevant repository (for example, an XSS in a UI component might belong in [`@hoppscotch/ui`](https://github.com/hoppscotch/ui)). If in doubt, open your report in `hoppscotch/hoppscotch` GHSA.
**Do not create a GitHub issue to report a security vulnerability.**
## What does not qualify as a vulnerability
Review the threat model above before reporting. The architecture and threat model section documents deliberate design decisions for each component. A finding that matches a known vulnerability class (CWE, OWASP category, or similar) is not automatically a vulnerability in this project; the threat model explains why. Reports that restate a documented design decision as a vulnerability will be closed without further analysis. The following are by design or out of scope.
**Intended Desktop app and Agent behaviour:**
- The relay or Agent sending requests to private IP ranges, localhost, or cloud metadata endpoints. This is the product's core function.
- Credentials, tokens, or API keys stored in local storage, the application data store, local log files, or local backups on the user's machine. Local data is protected by OS-level access controls.
- Debug-level log output containing request details including headers and authentication data. The same data already exists in the local data store.
- A self-hosted instance bundle having access to application data within the Desktop app after passing signature verification. Adding an instance is an explicit trust decision.
- Users disabling TLS host or peer verification for specific domains. This is an operator-controlled per-domain setting for working with self-signed or internal certificates.
- WebSocket, SSE, Socket.IO, or MQTT connections reaching user-specified endpoints, including internal addresses. These are separate realtime features under the same trust model as HTTP relay requests.
- Pre-request or post-request scripts from imported collections executing in the sandbox. The sandbox applies equally to imported and locally authored scripts.
- The Desktop app checking `releases.hoppscotch.com` for updates. No user data is transmitted; update manifests are signature-verified.
- The Agent accepting connections from any origin on localhost:9119. CORS is permissive by design; access control is enforced through the registration handshake and encrypted channel.
- The Agent's registration OTP having no expiry and registration attempts not being rate-limited. The security assumption, documented in the Agent section above, is that the user initiates registration intentionally while the Agent UI is visible on their own machine. This is not CWE-307 (improper restriction of excessive authentication attempts) because the Agent is a local service, not a remote authentication endpoint.
- Theoretical attacks against the Desktop app or Agent that require prior local access to the user's machine, since the attacker already has access to the same data through the operating system.
**Intended self-hosted behaviour:**
- First-run configuration endpoints being accessible without authentication before any administrator exists. These endpoints are intentionally unauthenticated during initial bootstrap so that the self-hosting organisation can complete setup, and are gated once an administrator is provisioned. This is not CWE-306 (missing authentication for critical function); it is the documented bootstrap path. Reports against an uninitialised instance describe the intended path. Bootstrap-related findings against an instance that has already been onboarded are a distinct issue and should be reported.
- Published collections being accessible without authentication via their public URL. The self-hosting organisation controls which collections are published. This is not CWE-284 (improper access control); publication is an explicit operator action.
- The auto-generated session secret used when `INFRA.SESSION_SECRET` is not set. The threat model already notes that auto-generated values are not suitable for production deployments. The self-hosting organisation is responsible for setting explicit secrets in their environment configuration.
- Some authenticated GraphQL mutations opting out of rate limiting. These opt-outs are intentional where throttling would interfere with normal interactive use and are scoped to authenticated sessions.
**Out of scope:**
- Vulnerabilities in dependencies without a demonstrated practical attack against Hoppscotch.
- Automated scanner output, AI-generated vulnerability reports, or generic security assessments that have not been validated against this document's architecture and threat model. A report must identify what specific security control is missing or bypassable in context, not merely flag a code pattern that matches a known vulnerability class. Tools that scan a codebase and produce findings without reading the threat model will generate false positives against this project.
- Applying a generic vulnerability classification to behaviour this document explains as intentional. Sending HTTP requests to user-specified private IP ranges is the product's core function, not server-side request forgery (CWE-918). Storing credentials in local files on the user's own machine is the expected data model for a single-user developer tool, not insecure credential storage (CWE-312). A permissive CORS policy on a localhost service with application-layer authentication is documented above, not a CORS misconfiguration (CWE-942).
- Missing HTTP security headers (Content-Security-Policy, Strict-Transport-Security, X-Frame-Options) on the self-hosted web client without a demonstrated attack that the header would have prevented in this application's deployment context.
- Findings against hoppscotch.io or hoppscotch.com; report through the platform's security channel. Cross-boundary reports involving both a self-hosted component and the cloud platform are accepted here and will be coordinated.

View file

@ -1,33 +0,0 @@
# Translations
Thanks for showing your interest in helping us to translate the software.
## Creating a new translation
Before you start working on a new language, please look through the [open pull requests](https://github.com/hoppscotch/hoppscotch/pulls) to see if anyone is already working on a translation. If you find one, please join the discussion and help us keep the existing translations up to date.
if there is no existing translation, you can create a new one by following these steps:
1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).**
2. **Checkout the `main` branch for latest translations.**
3. **Create a new branch for your translation with base branch `main`.**
4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.**
5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.**
6. **Translate the strings in the target language file.**
7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).**
8. **Save and commit changes.**
9. **Send a pull request.**
_You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._
Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests.
## Updating a translation
### Corrections
If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, [open an issue](https://github.com/hoppscotch/hoppscotch/issues/new/choose).
### Broken links
When tests find broken links, try to fix them across all translations. Ideally, only update the linked URLs, so that translation changes will definitely not be necessary.

View file

@ -1,59 +0,0 @@
# THIS IS NOT TO BE USED FOR PERSONAL DEPLOYMENTS!
# Internal Docker Compose Image used for internal testing deployments
services:
hoppscotch-db:
image: postgres:15
user: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpass
POSTGRES_DB: hoppscotch
healthcheck:
test:
[
"CMD-SHELL",
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'",
]
interval: 5s
timeout: 5s
retries: 10
hoppscotch-aio:
container_name: hoppscotch-aio
build:
dockerfile: prod.Dockerfile
context: .
target: aio
environment:
# DATABASE_URL is read from the .env file to allow the backend to connect with an external database.
# This allows the backend to retain existing data and prevents database resets during deployments.
# - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
- ENABLE_SUBPATH_BASED_ACCESS=true
env_file:
- ./.env
depends_on:
hoppscotch-db:
condition: service_healthy
command:
[
"sh",
"-c",
"pnpm exec prisma migrate deploy && node /usr/src/app/aio_run.mjs",
]
healthcheck:
test:
[
"CMD",
"curl",
"-f",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"http://localhost:80",
]
interval: 2s
timeout: 10s
retries: 30

View file

@ -1,160 +1,13 @@
# To make it easier to self-host, we have a preset docker compose config that also
# has a container with a Postgres instance running.
# You can tweak around this file to match your instances
# PROFILES EXPLANATION:
#
# We use Docker Compose profiles to manage different deployment scenarios and avoid port conflicts.
#
# These are all the available profiles:
# - default: All-in-one service + database + auto-migration (recommended for most users)
# - default-no-db: All-in-one service without database (for users with external DB)
# - backend: The backend service only
# - app: The main Hoppscotch application and the webapp server
# - admin: The self-host admin dashboard only
# - database: Just the PostgreSQL database
# - just-backend: All services except webapp for local development
# - deprecated: All deprecated services (not recommended)
# USAGE:
#
# To run the default setup: docker compose --profile default up
# To run without database: docker compose --profile default-no-db up
# To run specific components: docker compose --profile backend up
# To run all except webapp: docker compose --profile just-backend up
# To run deprecated services: docker compose --profile deprecated up
# NOTE: The default and default-no-db profiles should not be mixed with individual service
# profiles as they would conflict on ports.
services: services:
# This service runs the backend app in the port 3170
hoppscotch-backend:
profiles: ["backend", "just-backend", "app", "admin"]
container_name: hoppscotch-backend
build:
dockerfile: prod.Dockerfile
context: .
target: backend
env_file:
- ./.env
restart: always
environment:
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=8080
volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/
depends_on:
hoppscotch-db:
condition: service_healthy
ports:
- "3180:80"
- "3170:3170"
# The main hoppscotch app with integrated webapp server. This will be hosted at port 3000
# The webapp server will be accessible at port 3200
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
hoppscotch-app:
profiles: ["app"]
container_name: hoppscotch-app
build:
dockerfile: prod.Dockerfile
context: .
target: app
env_file:
- ./.env
depends_on:
- hoppscotch-backend
ports:
- "3080:80"
- "3000:3000"
- "3200:3200"
# The Self Host dashboard for managing the app. This will be hosted at port 3100
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
hoppscotch-sh-admin:
profiles: ["admin"]
container_name: hoppscotch-sh-admin
build:
dockerfile: prod.Dockerfile
context: .
target: sh_admin
env_file:
- ./.env
depends_on:
- hoppscotch-backend
ports:
- "3280:80"
- "3100:3100"
# The service that spins up all services at once in one container
hoppscotch-aio:
profiles: ["default"]
container_name: hoppscotch-aio
restart: unless-stopped
build:
dockerfile: prod.Dockerfile
context: .
target: aio
env_file:
- ./.env
depends_on:
hoppscotch-db:
condition: service_healthy
ports:
- "3000:3000"
- "3100:3100"
- "3170:3170"
- "3200:3200"
- "3080:80"
# Profile with no database dependency (purely developmental)
hoppscotch-aio-no-db:
profiles: ["default-no-db"]
container_name: hoppscotch-aio
restart: unless-stopped
build:
dockerfile: prod.Dockerfile
context: .
target: aio
env_file:
- ./.env
ports:
- "3000:3000"
- "3100:3100"
- "3170:3170"
- "3200:3200"
- "3080:80"
# The preset DB service, you can delete/comment the below lines if
# you are using an external postgres instance
# This will be exposed at port 5432
hoppscotch-db: hoppscotch-db:
profiles:
[
"default",
"database",
"just-backend",
"backend",
"app",
"admin",
"deprecated",
]
image: postgres:15 image: postgres:15
ports:
- "5432:5432"
user: postgres user: postgres
environment: environment:
# The default user defined by the docker image
POSTGRES_USER: postgres POSTGRES_USER: postgres
# NOTE: Please UPDATE THIS PASSWORD! POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-testpass}
POSTGRES_PASSWORD: testpass POSTGRES_DB: ${POSTGRES_DB:-hoppscotch}
POSTGRES_DB: hoppscotch volumes:
- hoppscotch-db:/var/lib/postgresql/data
healthcheck: healthcheck:
test: test:
[ [
@ -165,93 +18,32 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
# Auto-migration service - handles database migrations automatically hoppscotch-aio:
hoppscotch-migrate: container_name: hoppscotch-aio
profiles: ["default", "just-backend", "backend", "app", "admin"] restart: unless-stopped
build: build:
dockerfile: prod.Dockerfile dockerfile: prod.Dockerfile
context: . context: .
target: backend target: aio
env_file: env_file:
- ./.env - ./.env
depends_on:
hoppscotch-db:
condition: service_healthy
command: sh -c "pnpm exec prisma migrate deploy"
# All the services listed below are deprecated
# These services are kept for backward compatibility but should not be used for new deployments
hoppscotch-old-backend:
profiles: ["deprecated"]
container_name: hoppscotch-old-backend
build:
dockerfile: packages/hoppscotch-backend/Dockerfile
context: .
target: prod
env_file:
- ./.env
restart: always
environment: environment:
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well) DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-testpass}@hoppscotch-db:5432/${POSTGRES_DB:-hoppscotch}
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=3000
volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/
depends_on: depends_on:
hoppscotch-db: hoppscotch-db:
condition: service_healthy condition: service_healthy
command:
[
"sh",
"-c",
"pnpm exec prisma migrate deploy && node /usr/src/app/aio_run.mjs",
]
ports: ports:
- "3170:3000" - "3000:3000"
- "3100:3100"
- "3170:3170"
- "3200:3200"
- "3080:80"
hoppscotch-old-app: volumes:
profiles: ["deprecated"] hoppscotch-db:
container_name: hoppscotch-old-app
build:
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
context: .
env_file:
- ./.env
depends_on:
- hoppscotch-old-backend
ports:
- "3000:8080"
hoppscotch-old-sh-admin:
profiles: ["deprecated"]
container_name: hoppscotch-old-sh-admin
build:
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
context: .
env_file:
- ./.env
depends_on:
- hoppscotch-old-backend
ports:
- "3100:8080"
# DEPLOYMENT SCENARIOS:
# 1. Default deployment (recommended):
# docker compose --profile default up
# This will start: AIO + database + auto-migration
#
# 2. Default deployment without database:
# docker compose --profile default-no-db up
# This will start: AIO only (use when you have an external database)
#
# 3. Individual service deployment:
# docker compose --profile backend up # Just the backend
# docker compose --profile app up # Just the app and webapp server
# docker compose --profile admin up # Just the admin dashboard
# docker compose --profile database up # Just the database
#
# 4. Development deployment:
# docker compose --profile just-backend up # All services except webapp
#
# 5. Deprecated services:
# docker compose --profile deprecated up
# This will start all deprecated services (not recommended for new deployments)
#
# Remember: The default and default-no-db profiles should not be mixed with individual service
# profiles as they would conflict on ports.

View file

@ -0,0 +1,242 @@
# Coolify AIO Cleanup Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Reduce this Hoppscotch fork to the packages and deployment files required for Coolify AIO.
**Architecture:** Keep the existing AIO Docker architecture and remove code outside that deployment path. The workspace remains a pnpm monorepo, but `pnpm-workspace.yaml`, root scripts, and compose services are narrowed to the retained packages.
**Tech Stack:** pnpm workspaces, Docker Compose, multi-stage Dockerfile, NestJS backend, Vue/Vite frontends, Prisma, Caddy.
---
### Task 1: Remove Non-AIO Packages
**Files:**
- Delete: `packages/hoppscotch-desktop`
- Delete: `packages/hoppscotch-agent`
- Delete: `packages/hoppscotch-cli`
- Delete: `packages/hoppscotch-relay`
- [ ] **Step 1: Delete package directories**
Run:
```bash
rm -rf packages/hoppscotch-desktop packages/hoppscotch-agent packages/hoppscotch-cli packages/hoppscotch-relay
```
Expected: directories are absent from `find packages -maxdepth 1 -mindepth 1 -type d`.
- [ ] **Step 2: Verify retained package list**
Run:
```bash
find packages -maxdepth 1 -mindepth 1 -type d -printf '%f\n' | sort
```
Expected output contains only:
```text
codemirror-lang-graphql
hoppscotch-backend
hoppscotch-common
hoppscotch-data
hoppscotch-js-sandbox
hoppscotch-kernel
hoppscotch-selfhost-web
hoppscotch-sh-admin
```
### Task 2: Narrow Workspace And Root Scripts
**Files:**
- Modify: `pnpm-workspace.yaml`
- Modify: `package.json`
- [ ] **Step 1: Replace workspace glob**
Set `pnpm-workspace.yaml` to:
```yaml
packages:
- 'packages/codemirror-lang-graphql'
- 'packages/hoppscotch-backend'
- 'packages/hoppscotch-common'
- 'packages/hoppscotch-data'
- 'packages/hoppscotch-js-sandbox'
- 'packages/hoppscotch-kernel'
- 'packages/hoppscotch-selfhost-web'
- 'packages/hoppscotch-sh-admin'
```
- [ ] **Step 2: Simplify root scripts**
Keep scripts that make sense for the retained AIO deployment:
```json
{
"dev": "pnpm -r do-dev",
"gen-gql": "cross-env GQL_SCHEMA_EMIT_LOCATION='../../../gql-gen/backend-schema.gql' pnpm -r generate-gql-sdl",
"generate": "pnpm -r do-build-prod",
"start": "http-server packages/hoppscotch-selfhost-web/dist -p 3000",
"lint": "pnpm -r do-lint",
"typecheck": "pnpm -r do-typecheck",
"lintfix": "pnpm -r do-lintfix",
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
"test": "pnpm -r do-test"
}
```
Remove `generate-ui`, because it is not a Coolify deployment concern.
### Task 3: Simplify Docker Compose For Coolify AIO
**Files:**
- Modify: `docker-compose.yml`
- Delete: `docker-compose.deploy.yml`
- [ ] **Step 1: Replace compose file with AIO deployment**
Keep only `hoppscotch-db` and `hoppscotch-aio`. Preserve the AIO command:
```yaml
services:
hoppscotch-db:
image: postgres:15
user: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-testpass}
POSTGRES_DB: ${POSTGRES_DB:-hoppscotch}
volumes:
- hoppscotch-db:/var/lib/postgresql/data
healthcheck:
test:
[
"CMD-SHELL",
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'",
]
interval: 5s
timeout: 5s
retries: 10
hoppscotch-aio:
container_name: hoppscotch-aio
restart: unless-stopped
build:
dockerfile: prod.Dockerfile
context: .
target: aio
environment:
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-testpass}@hoppscotch-db:5432/${POSTGRES_DB:-hoppscotch}
env_file:
- ./.env
depends_on:
hoppscotch-db:
condition: service_healthy
command:
[
"sh",
"-c",
"pnpm exec prisma migrate deploy && node /usr/src/app/aio_run.mjs",
]
ports:
- "3000:3000"
- "3100:3100"
- "3170:3170"
- "3200:3200"
- "3080:80"
volumes:
hoppscotch-db:
```
- [ ] **Step 2: Delete internal deploy compose**
Run:
```bash
rm -f docker-compose.deploy.yml
```
Expected: `test ! -f docker-compose.deploy.yml` exits with status 0.
### Task 4: Remove Non-Coolify Root Files And Update Docs
**Files:**
- Delete: `firebase.json`
- Delete: `firestore.indexes.json`
- Delete: `firestore.rules`
- Delete: `netlify.toml`
- Delete: `CODEOWNERS`
- Delete: `CODE_OF_CONDUCT.md`
- Delete: `CONTRIBUTING.md`
- Delete: `SECURITY.md`
- Delete: `TRANSLATIONS.md`
- Modify: `README.md`
- Modify: `.dockerignore`
- [ ] **Step 1: Delete unused root files**
Run:
```bash
rm -f firebase.json firestore.indexes.json firestore.rules netlify.toml CODEOWNERS CODE_OF_CONDUCT.md CONTRIBUTING.md SECURITY.md TRANSLATIONS.md
```
- [ ] **Step 2: Replace README with Coolify-focused content**
Use a short README covering local compose and Coolify variables.
- [ ] **Step 3: Keep Docker context lean**
Update `.dockerignore` so it excludes local files, build outputs, package test
artifacts, docs, and removed platform files while keeping source and lockfiles.
### Task 5: Validate And Commit
**Files:**
- Verify all modified files
- [ ] **Step 1: Check references to removed packages**
Run:
```bash
rg -n "hoppscotch-(desktop|agent|cli|relay)|@hoppscotch/cli|docker-compose.deploy|firebase|netlify" .
```
Expected: no deployment-relevant references remain. Historical references in
lockfile may remain until `pnpm install` updates the lockfile.
- [ ] **Step 2: Validate compose syntax**
Run:
```bash
docker compose config
```
Expected: command exits 0 and prints a normalized compose config.
- [ ] **Step 3: Update lockfile if possible**
Run:
```bash
pnpm install --lockfile-only
```
Expected: command exits 0 and removes deleted packages from `pnpm-lock.yaml`.
- [ ] **Step 4: Commit**
Run:
```bash
git add -A
git add -f docs/superpowers/specs/2026-05-06-coolify-aio-cleanup-design.md docs/superpowers/plans/2026-05-06-coolify-aio-cleanup.md
git commit -m "chore: trim repo for coolify aio"
```

View file

@ -0,0 +1,203 @@
# Require Web Login Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Block the self-hosted web app UI until a user is authenticated.
**Architecture:** Add a small web-only root extension component registered by `packages/hoppscotch-selfhost-web/src/main.ts`. The component observes the existing auth platform state and renders a full-screen login gate only for web mode while no confirmed user exists.
**Tech Stack:** Vue 3, Hoppscotch platform auth streams, RxJS `BehaviorSubject`, Vitest for focused state logic.
---
### Task 1: Auth Gate State Logic
**Files:**
- Create: `packages/hoppscotch-common/src/helpers/appLoginGate.ts`
- Test: `packages/hoppscotch-common/src/helpers/__tests__/appLoginGate.spec.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { describe, expect, test } from "vitest"
import { shouldBlockAppForLogin } from "../appLoginGate"
describe("shouldBlockAppForLogin", () => {
const user = {
uid: "user-1",
displayName: "User",
email: "user@example.com",
photoURL: null,
emailVerified: true,
}
test("blocks the web app while auth is still being checked", () => {
expect(
shouldBlockAppForLogin({
platform: "web",
isAuthInitComplete: false,
currentUser: null,
})
).toBe(true)
})
test("blocks the web app when auth is confirmed anonymous", () => {
expect(
shouldBlockAppForLogin({
platform: "web",
isAuthInitComplete: true,
currentUser: null,
})
).toBe(true)
})
test("does not block the web app once a user is authenticated", () => {
expect(
shouldBlockAppForLogin({
platform: "web",
isAuthInitComplete: true,
currentUser: user,
})
).toBe(false)
})
test("does not block desktop", () => {
expect(
shouldBlockAppForLogin({
platform: "desktop",
isAuthInitComplete: true,
currentUser: null,
})
).toBe(false)
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm --dir packages/hoppscotch-common exec vitest --run src/helpers/__tests__/appLoginGate.spec.ts`
Expected: FAIL because `../appLoginGate` does not exist.
- [ ] **Step 3: Write minimal implementation**
```ts
type KernelMode = "web" | "desktop"
export type LoginGateState = {
platform: KernelMode
isAuthInitComplete: boolean
currentUser: unknown | null
}
export function shouldBlockAppForLogin(state: LoginGateState) {
return (
state.platform === "web" &&
(!state.isAuthInitComplete || !state.currentUser)
)
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm --dir packages/hoppscotch-common exec vitest --run src/helpers/__tests__/appLoginGate.spec.ts`
Expected: PASS.
### Task 2: Web Login Gate UI
**Files:**
- Create: `packages/hoppscotch-selfhost-web/src/components/WebLoginGate.vue`
- Create: `packages/hoppscotch-selfhost-web/src/services/webLoginGate.service.ts`
- Modify: `packages/hoppscotch-selfhost-web/src/main.ts`
- [ ] **Step 1: Create the root UI extension registration service**
Create `packages/hoppscotch-selfhost-web/src/services/webLoginGate.service.ts`:
```ts
import { Service } from "dioc"
import { getService } from "@hoppscotch/common/modules/dioc"
import { UIExtensionService } from "@hoppscotch/common/services/ui-extension.service"
import WebLoginGate from "@app/components/WebLoginGate.vue"
export class WebLoginGateService extends Service {
public static readonly ID = "WEB_LOGIN_GATE_SERVICE"
override onServiceInit() {
getService(UIExtensionService).addRootUIExtension(WebLoginGate)
}
}
```
- [ ] **Step 2: Register the service for web only**
In `packages/hoppscotch-selfhost-web/src/main.ts`, import the service:
```ts
import { WebLoginGateService } from "@app/services/webLoginGate.service"
```
Then change:
```ts
addedServices: [],
```
to:
```ts
addedServices: platform === "web" ? [WebLoginGateService] : [],
```
- [ ] **Step 3: Implement the blocking component**
Create `packages/hoppscotch-selfhost-web/src/components/WebLoginGate.vue`:
```vue
<template>
<Teleport to="body">
<div
v-if="shouldBlockApp"
class="fixed inset-0 z-50 flex min-h-screen flex-col items-center justify-center bg-primary p-6"
>
<AppLogo class="mb-8 h-16 w-16 rounded" />
<FirebaseLogin />
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { useReadonlyStream } from "@hoppscotch/common/composables/stream"
import { shouldBlockAppForLogin } from "@hoppscotch/common/helpers/appLoginGate"
import { platform } from "@hoppscotch/common/platform"
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const shouldBlockApp = computed(() =>
shouldBlockAppForLogin({
platform: "web",
isAuthInitComplete: true,
currentUser: currentUser.value,
})
)
</script>
```
- [ ] **Step 4: Run targeted checks**
Run: `pnpm --dir packages/hoppscotch-common exec vitest --run src/helpers/__tests__/appLoginGate.spec.ts`
Expected: PASS.
Run: `pnpm --dir packages/hoppscotch-selfhost-web run lint`
Expected: PASS or only pre-existing unrelated failures.
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/plans/2026-05-06-require-web-login.md packages/hoppscotch-common/src/helpers/appLoginGate.ts packages/hoppscotch-common/src/helpers/__tests__/appLoginGate.spec.ts packages/hoppscotch-selfhost-web/src/components/WebLoginGate.vue packages/hoppscotch-selfhost-web/src/services/webLoginGate.service.ts packages/hoppscotch-selfhost-web/src/main.ts
git commit -m "feat: require login for web app"
```

View file

@ -0,0 +1,72 @@
# Coolify AIO Cleanup Design
## Goal
Prepare this Hoppscotch fork as an internal AIO deployment repository for Coolify.
The repository should keep only the web app, backend, admin UI, database
migration path, and workspace packages required to build the all-in-one Docker
image.
## Keep
- `packages/hoppscotch-selfhost-web`: main self-hosted web app and webapp server.
- `packages/hoppscotch-backend`: NestJS backend, Prisma schema, migrations, and
backend Caddy config.
- `packages/hoppscotch-sh-admin`: self-host admin dashboard used by AIO.
- `packages/hoppscotch-common`: shared frontend UI/runtime code used by the app.
- `packages/hoppscotch-data`: shared data models and migrations used by frontend
packages and sandbox code.
- `packages/hoppscotch-kernel`: runtime abstraction used by the web app/common
package.
- `packages/hoppscotch-js-sandbox`: script/test sandbox used by the web app.
- `packages/codemirror-lang-graphql`: CodeMirror GraphQL language package used by
`hoppscotch-common`.
- Root Docker/Caddy/runtime files required by `prod.Dockerfile`.
## Remove
- Desktop-only code: `packages/hoppscotch-desktop`.
- Agent-only code: `packages/hoppscotch-agent`.
- CLI-only code: `packages/hoppscotch-cli`.
- Rust relay source: `packages/hoppscotch-relay`.
- Deprecated Docker services that build removed package Dockerfiles.
- Deployment/config files for platforms outside Coolify AIO: Firebase and Netlify.
- Upstream community repository documents that do not serve the internal fork.
## Docker Deployment Shape
Coolify should use the main `docker-compose.yml`. The compose file should expose
one supported deployment mode:
- `hoppscotch-aio`: built from `prod.Dockerfile` target `aio`.
- `hoppscotch-db`: local PostgreSQL 15 service for deployments that do not use an
external database.
The AIO command keeps the existing production behavior:
```sh
pnpm exec prisma migrate deploy && node /usr/src/app/aio_run.mjs
```
This preserves automatic migrations on container start while keeping Coolify
configuration small.
## Build Strategy
Keep the upstream multi-stage `prod.Dockerfile` structure for the first cleanup.
It is already tied to workspace postinstall scripts, Prisma generation, GraphQL
generation, the webapp server Go build, and Caddy. The aggressive cleanup comes
from removing packages and narrowing the workspace, not from rewriting the
Dockerfile copy/install strategy in the same change.
Add or keep a Docker ignore file that excludes VCS metadata, local caches,
dependency folders, build outputs, test artifacts, docs, and removed platform
files from the Docker build context.
## Validation
- Verify `pnpm-workspace.yaml` lists only retained packages.
- Verify root scripts do not invoke removed packages.
- Verify `docker-compose.yml` references only retained services and files.
- Verify the AIO Docker config is syntactically valid with `docker compose config`.
- Run a focused package install/build check if dependencies are available locally.

View file

@ -0,0 +1,55 @@
# Require Web Login Design
## Goal
When a user opens the self-hosted Hoppscotch web frontend without an active
session, the app must not be usable. The user should see only a login page
until authentication succeeds.
## Scope
- Applies only to the web shell in `packages/hoppscotch-selfhost-web`.
- Does not change the desktop shell behavior.
- Does not change backend authentication endpoints or session semantics.
- Reuses the existing Hoppscotch auth platform and login UI.
## Architecture
The self-hosted web platform will register a root UI extension that acts as an
auth gate. The gate will subscribe to the existing `platform.auth` user stream
and render above the app only when the current platform is web and no confirmed
user exists.
The gate has three visible states:
- Auth check pending: show a centered spinner while `performAuthInit()` verifies
the cookie-backed session.
- Anonymous: show a full-screen login-only page using the existing
`FirebaseLogin` component.
- Authenticated: render nothing, allowing the normal Hoppscotch app to remain
usable.
## Data Flow
`performAuthInit()` already checks the backend session and updates
`currentUser$`. The auth gate must not perform its own token or cookie checks.
It only observes the existing stream, so login, logout, refresh, and local auth
continue to use the current implementation.
## Route Behavior
The gate blocks interaction with normal app routes by covering the UI, not by
redirecting. This avoids changing common router behavior and avoids breaking
special routes such as `/enter` and `/device-login`, which use the `empty`
layout and are outside the normal application shell.
## Testing
Add a focused unit test for the gate state logic:
- pending auth check requires the blocking screen;
- anonymous confirmed state requires the blocking screen;
- authenticated state does not block;
- desktop platform does not block.
Run the targeted test and a typecheck or lint command for the touched package.

View file

@ -1,19 +0,0 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"hosting": {
"predeploy": [
"mv .env.example .env && npm install -g pnpm && pnpm i && pnpm run generate"
],
"public": "packages/hoppscotch-web/dist",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}

View file

@ -1,4 +0,0 @@
{
"indexes": [],
"fieldOverrides": []
}

View file

@ -1,13 +0,0 @@
service cloud.firestore {
match /databases/{database}/documents {
// Make sure the uid of the requesting user matches name of the user
// document. The wildcard expression {userId} makes the userId variable
// available in rules.
match /users/{userId} {
allow read, write, create, update, delete: if request.auth.uid != null && request.auth.uid == userId;
}
match /users/{userId}/{document=**} {
allow read, write, create, update, delete: if request.auth.uid != null && request.auth.uid == userId;
}
}
}

View file

@ -1,77 +0,0 @@
[build.environment]
NODE_VERSION = "14"
NPM_FLAGS = "--prefix=/dev/null"
[build]
base = "/"
publish = "packages/hoppscotch-web/dist"
command = "npx pnpm i --store=node_modules/.pnpm-store && npx pnpm run generate"
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "SAMEORIGIN"
X-XSS-Protection = "1; mode=block"
[[redirects]]
from = "/discord"
to = "https://discord.gg/GAMWxmR"
status = 301
force = true
[[redirects]]
from = "/telegram"
to = "https://t.me/hoppscotch"
status = 301
force = true
[[redirects]]
from = "/beta"
to = "https://forms.gle/XPYDMp8m6JHNWcYp9"
status = 301
force = true
[[redirects]]
from = "/careers"
to = "https://company.hoppscotch.io/careers"
status = 301
force = true
[[redirects]]
from = "/newsletter"
to = "http://eepurl.com/hy0eWH"
status = 301
force = true
[[redirects]]
from = "/twitter"
to = "https://x.com/hoppscotch_io"
status = 301
force = true
[[redirects]]
from = "/github"
to = "https://github.com/hoppscotch/hoppscotch"
status = 301
force = true
[[redirects]]
from = "/announcements"
to = "https://company.hoppscotch.io/announcements"
status = 301
force = true
[[redirects]]
from = "/robots.txt"
to = "/robots.txt"
status = 200
[[redirects]]
from = "/sitemap.xml"
to = "/sitemap.xml"
status = 200
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

View file

@ -17,11 +17,17 @@
"typecheck": "pnpm -r do-typecheck", "typecheck": "pnpm -r do-typecheck",
"lintfix": "pnpm -r do-lintfix", "lintfix": "pnpm -r do-lintfix",
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck", "pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
"test": "pnpm -r do-test", "test": "pnpm -r do-test"
"generate-ui": "pnpm -r do-build-ui"
}, },
"workspaces": [ "workspaces": [
"./packages/*" "./packages/codemirror-lang-graphql",
"./packages/hoppscotch-backend",
"./packages/hoppscotch-common",
"./packages/hoppscotch-data",
"./packages/hoppscotch-js-sandbox",
"./packages/hoppscotch-kernel",
"./packages/hoppscotch-selfhost-web",
"./packages/hoppscotch-sh-admin"
], ],
"devDependencies": { "devDependencies": {
"@commitlint/cli": "20.5.2", "@commitlint/cli": "20.5.2",

View file

@ -1,33 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

View file

@ -1,281 +0,0 @@
<div align="center">
<img align="center" width="128px" src="public/icon.png" />
<h1 align="center"><b>Hoppscotch Agent</b></h1>
<h2 align="center">
<a href="https://github.com/hoppscotch/agent-releases">Download</a> |
<a href="https://docs.hoppscotch.io/documentation/clients/agent">Official Docs</a>
</h2>
</div>
<br/>
#### Hoppscotch Agent is a cross-platform HTTP request relay for Hoppscotch built with [Tauri V2](https://v2.tauri.app/) that adds capabilities like custom headers, certificates, proxies, and local access typically restricted in browsers.
The agent runs as a local system service on port `9119`, acting as an intermediary between the Hoppscotch web application and target APIs. It establishes an encrypted communication channel authenticated via an OTP registration process.
## Installation
### Standard Installation
1. Download the latest version of Hoppscotch Agent from [releases](https://github.com/hoppscotch/agent-releases)
2. Run the installer
3. Follow the installation wizard to complete setup
4. The agent automatically starts and appears in your system tray
### Portable Version
The portable version runs without installation and does not include automatic updates.
1. Download the portable version for your operating system
2. Extract the archive to your desired location
3. Run the executable directly
4. The agent will start and appear in your system tray
> [!Note]
> The portable version uses a separate configuration (`tauri.portable.conf.json`) that disables bundling and updater functionality.
## Getting Started
### Registration
1. Open Hoppscotch web app and navigate to **Settings** → **Interceptors**
2. Select **Agent** from the available interceptors
3. Click **Register Agent** button
4. The agent window displays a 6-digit verification code
5. Enter the verification code in the OTP input field
6. Click the confirm button to establish connection
7. The agent displays a masked auth key hash when successfully registered
### Usage
Once registered, all HTTP requests made through Hoppscotch are processed by the agent. The agent provides:
- CORS bypass by processing requests locally
- Client certificate authentication for mutual TLS
- HTTP Digest Authentication using challenge-response mechanisms
- Custom headers that browsers typically restrict
- Proxy routing with authentication support
- Local network and localhost access
- SSL/TLS verification controls
- And much more
## Domain-Specific Configuration
The agent (and `Native`) interceptor supports per-domain configuration overrides with a global default (`*`) domain:
### Domain Management
- **Global Defaults**: Base settings applied to all domains (domain: `*`)
- **Domain Overrides**: Specific settings for individual domains (e.g., `api.example.com`)
- **Domain Addition**: Add new domains through the domain management modal
- **Domain Removal**: Remove custom domain configurations (global default cannot be removed)
### SSL/TLS Security Settings
For each domain, configure:
- **Verify Host**: Enable/disable hostname verification during SSL handshake
- **Verify Peer**: Enable/disable peer certificate verification
- **CA Certificates**: Upload custom Certificate Authority certificates for domain validation
- **Client Certificates**: Configure client certificates for mutual TLS authentication
## Client Certificates
The agent supports client certificate authentication for APIs requiring mutual TLS:
### Certificate Formats
- **.pem certificates**: Requires separate certificate (.crt/.cer/.pem) and private key (.key/.pem) files
- **.pfx/.pkcs12 certificates**: Single file format with optional password protection
### Configuration
1. Access **Settings****Interceptors****Agent** in Hoppscotch
2. Select the target domain from the domain selector
3. Click **Client Certificates** button
4. Choose certificate format (PEM or PFX tab)
5. Upload certificate files:
- **PEM**: Upload certificate file and private key file separately
- **PFX**: Upload .pfx/.pkcs12 file and enter password if required
6. Configuration is automatically saved per domain
### CA Certificates
Custom Certificate Authority certificates can be added per domain:
1. Navigate to the CA Certificates section for the target domain
2. Click **Add Certificate File**
3. Upload the CA certificate file
4. Toggle certificate inclusion on/off as needed
5. Remove certificates using the trash icon
## Proxy Configuration
The agent supports HTTP/HTTPS proxy routing with authentication (including NTLM):
### Proxy Settings
- **Proxy URL**: HTTP/HTTPS proxy server address with port
- **Proxy Authentication**: Username and password for proxy server authentication
- **Per-Domain**: Each domain can have different proxy configurations
### Configuration
1. Select the target domain
2. Toggle the **Proxy** switch to enable
3. Enter the proxy URL (e.g., `http://proxy.example.com:8080`)
4. Configure proxy authentication if required:
- Username field
- Password field (with show/hide toggle)
## System Integration
### System Tray
The agent runs with system tray integration, providing access to:
- **Show Registrations**: View active connections and registration status
- **Clear Registrations**: Remove all registered instances
- **Maximize Window**: Show the agent interface window
- **Quit**: Exit the agent application
### Configuration Storage
The agent stores configuration in platform-specific locations:
- **Windows**: `%APPDATA%\io.hoppscotch.agent\`
- **macOS**: `~/Library/Application Support/io.hoppscotch.agent/`
- **Linux**: `~/.config/io.hoppscotch.agent/`
### Logging
Logs are stored in platform-specific directories:
- **Windows**: `%LOCALAPPDATA%\io.hoppscotch.agent\logs\`
- **macOS**: `~/Library/Logs/io.hoppscotch.agent/`
- **Linux**: `~/.local/share/io.hoppscotch.agent/logs/`
### Auto-Start Configuration
The standard installation includes auto-start functionality. The portable version does not include auto-start and must be launched manually.
## Building from Source
### Prerequisites
- [Node.js](https://nodejs.org/) (v18 or later)
- [pnpm](https://pnpm.io/) package manager
- [Rust](https://rustup.rs/) (latest stable)
- [Tauri CLI](https://tauri.app/v1/guides/getting-started/prerequisites)
### Development
```bash
# Clone the repository
git clone https://github.com/hoppscotch/hoppscotch.git
cd hoppscotch/packages/hoppscotch-agent
# Install dependencies
pnpm install
# Start development server
pnpm tauri dev
```
### Production Build
```bash
# Build standard version
pnpm tauri build
# Build portable version
pnpm tauri build --config src-tauri/tauri.portable.conf.json
```
The built applications will be available in `src-tauri/target/release/bundle/`
### Build Variants
Two build configurations are available:
- **Standard** (`tauri.conf.json`): Includes installer, auto-updater, and auto-start functionality
- **Portable** (`tauri.portable.conf.json`): Standalone executable without installation requirements
## Network Configuration
### Default Port
The agent runs on port `9119` by default. Make sure this port is not blocked by firewalls.
### Communication Protocol
- **Encryption**: AES-256-GCM for all agent-to-web-app communication
- **Authentication**: X25519 key exchange for secure channel establishment
- **Registration**: One-time 6-digit OTP verification process
## System Requirements
### Windows
- **OS Version**: Windows 10 1803+ or Windows 11
- **Architecture**: x64
- **Dependencies**: WebView2 Runtime (auto-installed for standard version)
### macOS
- **OS Version**: macOS 10.15 (Catalina) or later
- **Architecture**: Intel x64 or Apple Silicon (ARM64)
### Linux
- **Architecture**: x64
- **Dependencies**: WebKit2GTK 2.44.0+ (usually pre-installed)
- **Minimum**: Systems with GLIBC 2.38+
## Troubleshooting
### Agent Detection Issues
1. **"Agent not detected" popup**: Verify the agent is running by checking the system tray for the Hoppscotch icon
2. **Switching interceptors blocked**: If the "Agent not detected" popup prevents switching interceptors, restart your browser and stop the agent before changing interceptor settings
3. **Port accessibility**: Check that no firewall is blocking port `9119`
4. **Browser compatibility**: Safari on macOS may have CORS issues with localhost:9119 due to access control checks, try Chrome/Firefox for agent registration
### Registration Failures
1. **"Failed to initiate the registration"**: This error may occur due to browser security policies or extension conflicts
2. **Missing OTP input field**: Verify the agent window is focused and displaying a 6-digit verification code
3. **OTP expiration**: Registration codes have limited lifetime, restart the registration process if the code expires
4. **Network connectivity**: Verify browser can reach localhost:9119/handshake
5. **Version compatibility**: Some agent versions may be incompatible with specific Hoppscotch web app versions. For self-hosted setups, make sure Agent version in the release matches, see https://github.com/hoppscotch/hoppscotch/issues/4936#issuecomment-2756981053
### Certificate Issues
1. Verify certificate format is supported (.pem or .pfx/.pkcs12)
2. Check certificate expiration dates
3. Confirm private key matches certificate (for .pem files)
4. Verify domain configuration matches target API hostname
5. Confirm certificate password is correct (for .pfx/.pkcs12)
6. Check CA certificate inclusion status (toggle on/off)
### Request Processing Issues
1. **Custom headers not applied**: Verify the agent is selected as interceptor, browsers may override headers like User-Agent when using default HTTP methods
2. **CORS errors**: Confirm agent interceptor is active and requests are routing through localhost:9119
3. **SSL/TLS verification**: Check verify host/peer settings for the target domain
4. **Proxy routing**: Verify proxy URL format includes protocol (http:// or https://)
### System-Specific Issues
#### Windows
1. Check WebView2 Runtime is installed (auto-installed with standard version)
2. Check Windows Defender or antivirus exclusions for the agent executable
3. Verify agent has network permissions through Windows Firewall
#### macOS
1. Safari browser may block agent connections due to CORS policies, try Chrome or Firefox instead
2. Check macOS Gatekeeper settings if agent fails to start
3. Verify agent is allowed in System Preferences → Security & Privacy
#### Linux
1. Check WebKit2GTK dependencies are installed
2. Check systemd logs if agent fails to start as service
3. Verify GLIBC version compatibility (requires 2.38+)
### Portable Version Issues
1. Manual WebView2 installation - may be required on older versions of Windows
2. No auto-start capability - must launch manually after system restart
3. No automatic updates - download new versions manually
4. Verify executable permissions on Unix-like systems
5. Check that portable version is extracted to a writable directory
### Log
Check agent logs for detailed error information:
- **Windows**: `%LOCALAPPDATA%\io.hoppscotch.agent\logs\`
- **macOS**: `~/Library/Logs/io.hoppscotch.agent/`
- **Linux**: `~/.local/share/io.hoppscotch.agent/logs/`
Look for connection errors, certificate validation failures, or proxy authentication issues in the log files.

View file

@ -1,67 +0,0 @@
import pluginVue from "eslint-plugin-vue"
import {
defineConfigWithVueTs,
vueTsConfigs,
} from "@vue/eslint-config-typescript"
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"
import globals from "globals"
export default defineConfigWithVueTs(
{
ignores: [
"**/*.d.ts",
"dist/**",
"node_modules/**",
"src-tauri/**",
],
},
pluginVue.configs["flat/recommended"],
vueTsConfigs.recommended,
eslintPluginPrettierRecommended,
{
files: ["**/*.ts", "**/*.js", "**/*.vue"],
linterOptions: {
reportUnusedDisableDirectives: false,
},
languageOptions: {
sourceType: "module",
ecmaVersion: "latest",
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
requireConfigFile: false,
ecmaFeatures: {
jsx: false,
},
},
},
rules: {
semi: [2, "never"],
"no-console": "off",
"no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"prettier/prettier":
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"vue/multi-word-component-names": "off",
"vue/no-side-effects-in-computed-properties": "off",
"@typescript-eslint/no-unused-vars": [
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
{
args: "all",
argsIgnorePattern: "^_",
caughtErrors: "all",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-function-type": "off",
"no-undef": "off",
},
}
)

View file

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hoppscotch Agent</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -1,52 +0,0 @@
{
"name": "hoppscotch-agent",
"private": true,
"version": "0.1.17",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "eslint src",
"lint:ts": "vue-tsc --noEmit",
"lintfix": "eslint --fix src",
"prod-lint": "cross-env HOPP_LINT_FOR_PROD=true pnpm run lint",
"do-lint": "pnpm run prod-lint",
"do-typecheck": "pnpm run lint:ts",
"do-lintfix": "pnpm run lintfix"
},
"dependencies": {
"@hoppscotch/ui": "0.2.5",
"@tauri-apps/api": "2.1.1",
"@tauri-apps/plugin-shell": "2.3.3",
"@vueuse/core": "14.2.1",
"axios": "1.15.2",
"fp-ts": "2.16.11",
"lodash-es": "4.18.1",
"vue": "3.5.33"
},
"devDependencies": {
"@iconify-json/lucide": "1.2.104",
"@tauri-apps/cli": "2.9.3",
"@types/lodash-es": "4.17.12",
"@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.59.0",
"@typescript-eslint/parser": "8.59.0",
"@vitejs/plugin-vue": "6.0.6",
"@vue/eslint-config-typescript": "14.7.0",
"autoprefixer": "10.5.0",
"cross-env": "10.1.0",
"eslint": "9.39.2",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-vue": "10.9.0",
"globals": "16.5.0",
"postcss": "8.5.10",
"tailwindcss": "3.4.16",
"typescript": "5.9.3",
"unplugin-icons": "22.5.0",
"unplugin-vue-components": "30.0.0",
"vite": "7.3.2",
"vue-tsc": "2.2.0"
}
}

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,25 +0,0 @@
# Enable static linking for C runtime library on Windows.
#
# Rust uses the msvc toolchain on Windows,
# which by default dynamically links the C runtime (CRT) to the binary.
#
# This creates a runtime dependency on the Visual C++ Redistributable (`vcredist`),
# meaning the target machine must have `vcredist` installed for the application to run.
#
# Since `portable` version doesn't have an installer,
# we can't rely on it to install dependencies, so this config.
#
# Basically:
# - The `+crt-static` flag instructs the Rust compiler to statically link the C runtime for Windows builds.\
# - To avoids runtime errors related to missing `vcredist` installations.
# - Results in a larger binary size because the runtime is bundled directly into the executable.
#
# For MSVC targets specifically, it will compile code with `/MT` or static linkage.
# See: - RFC 1721: https://rust-lang.github.io/rfcs/1721-crt-static.html
# - Rust Reference - Runtime: https://doc.rust-lang.org/reference/runtime.html
# - MSVC Linking Options: https://docs.microsoft.com/en-us/cpp/build/reference/md-mt-ld-use-run-time-library
# - Rust Issue #37406: https://github.com/rust-lang/rust/issues/37406
# - Tauri Issue #3048: https://github.com/tauri-apps/tauri/issues/3048
# - Rust Linkage: https://doc.rust-lang.org/reference/linkage.html
[target.'cfg(windows)']
rustflags = ["-C", "target-feature=+crt-static"]

View file

@ -1,7 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

File diff suppressed because it is too large Load diff

View file

@ -1,57 +0,0 @@
[package]
name = "hoppscotch-agent"
version = "0.1.17"
description = "A cross-platform HTTP request agent for Hoppscotch for advanced request handling including custom headers, certificates, proxies, and local system integration."
authors = ["AndrewBastin", "CuriousCorrelation"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "hoppscotch_agent_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.2", features = [] }
[dependencies]
tauri = { version = "2.9.3", features = ["tray-icon", "image-png"] }
tauri-plugin-shell = "2.3.3"
tauri-plugin-autostart = { version = "2.5.1", optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.48.0", features = ["full"] }
dashmap = { version = "6.1.0", features = ["serde"] }
axum = { version = "0.7.9" }
axum-extra = { version = "0.9.6", features = ["typed-header"] }
tower-http = { version = "0.6.6", features = ["cors"] }
tokio-util = "0.7.17"
uuid = { version = "1.18.1", features = [ "v4", "fast-rng" ] }
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8.5"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json", "fmt", "std", "time"] }
tracing-appender = "0.2.3"
relay = { git = "https://github.com/CuriousCorrelation/relay.git" }
thiserror = "1.0.69"
tauri-plugin-store = "2.4.1"
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
base16 = "0.2.1"
aes-gcm = { version = "0.10.3", features = ["aes"] }
tauri-plugin-updater = "2.9.0"
tauri-plugin-dialog = "2.4.2"
lazy_static = "1.5.0"
tauri-plugin-single-instance = "2.3.6"
tauri-plugin-http = { version = "2.5.4", features = ["gzip"] }
native-dialog = "0.7.0"
sha2 = "0.10.9"
file-rotate = "0.8.0"
dirs = "6.0.0"
[target.'cfg(windows)'.dependencies]
tempfile = { version = "3.23.0" }
winreg = { version = "0.52.0" }
[features]
default = ["tauri-plugin-autostart"]
portable = []

View file

@ -1,5 +0,0 @@
fn main() {
tauri_build::build();
println!("cargo::rerun-if-env-changed=UPDATER_PUB_KEY");
println!("cargo::rerun-if-env-changed=UPDATER_URL");
}

View file

@ -1,25 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main", "test"],
"permissions": [
{
"identifier": "http:default",
"allow": [
{
"url": "https://*.tauri.app"
},
{
"url": "https://*.microsoft.*"
}
]
},
"core:default",
"shell:allow-open",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-set-always-on-top"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,40 +0,0 @@
use std::sync::Arc;
use crate::{
model::{MaskedRegistration, RegistrationsList},
state::AppState,
util::generate_auth_key_hash,
};
#[tauri::command]
#[tracing::instrument(skip(state))]
pub async fn get_otp(state: tauri::State<'_, Arc<AppState>>) -> Result<Option<String>, ()> {
tracing::debug!("Retrieving current OTP");
let otp = state.active_registration_code.read().await.clone();
if otp.is_some() {
tracing::debug!("OTP found");
} else {
tracing::debug!("No active OTP");
}
Ok(otp)
}
#[tauri::command]
#[tracing::instrument(skip(state))]
pub fn list_registrations(state: tauri::State<'_, Arc<AppState>>) -> Result<RegistrationsList, ()> {
tracing::debug!("Retrieving registrations list");
let masked_registrations = state
.get_registrations()
.iter()
.map(|entry| MaskedRegistration {
registered_at: entry.value().registered_at,
auth_key_hash: generate_auth_key_hash(entry.key()),
})
.collect();
Ok(RegistrationsList {
registrations: masked_registrations,
total: state.get_registrations().len(),
})
}

View file

@ -1,399 +0,0 @@
use std::sync::Arc;
use axum::{
body::Bytes,
extract::{Path, State},
http::HeaderMap,
Json,
};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use chrono::Utc;
use rand::Rng;
use serde_json::json;
use tauri::{AppHandle, Emitter};
use uuid::Uuid;
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::{
error::{AgentError, AgentResult},
global::NONCE,
model::{
AuthKeyResponse, ConfirmedRegistrationRequest, HandshakeResponse, LogEntry, LogLevel,
MaskedRegistration, Registration,
},
state::AppState,
util::{generate_auth_key_hash, EncryptedJson},
};
#[tracing::instrument]
fn generate_otp() -> String {
let otp: u32 = rand::thread_rng().gen_range(0..1_000_000);
let formatted = format!("{:06}", otp);
tracing::debug!("Generated OTP: {}", formatted);
formatted
}
#[tracing::instrument(skip(app_handle))]
pub async fn handshake(
State((_, app_handle)): State<(Arc<AppState>, AppHandle)>,
) -> AgentResult<Json<HandshakeResponse>> {
tracing::info!("Processing handshake request");
let response = HandshakeResponse {
status: "success".to_string(),
__hoppscotch__agent__: true,
agent_version: app_handle.package_info().version.to_string(),
};
tracing::info!("Handshake successful");
Ok(Json(response))
}
#[tracing::instrument(skip(state, app_handle))]
pub async fn receive_registration(
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
) -> AgentResult<Json<serde_json::Value>> {
let otp = generate_otp();
tracing::info!("Generated new registration OTP");
let mut active_registration_code = state.active_registration_code.write().await;
if !active_registration_code.is_none() {
tracing::warn!("Registration attempt while another registration is active");
return Ok(Json(
json!({ "message": "There is already an existing registration happening" }),
));
}
*active_registration_code = Some(otp.clone());
match app_handle.emit("registration-received", otp) {
Ok(_) => {
tracing::info!("Registration event emitted successfully");
Ok(Json(
json!({ "message": "Registration received and stored" }),
))
}
Err(e) => {
tracing::error!("Failed to emit registration event: {}", e);
Err(AgentError::InternalServerError)
}
}
}
#[tracing::instrument(skip(state, _app_handle))]
pub async fn registration(
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
) -> AgentResult<EncryptedJson<MaskedRegistration>> {
let token = auth_header.token();
if !state.validate_access(token) {
tracing::warn!("Unauthorized attempt to list registrations");
return Err(AgentError::Unauthorized);
}
let registration = state
.get_registration(token)
.ok_or(AgentError::Unauthorized)?;
let key_b16 = registration.shared_secret_b16;
let registration = MaskedRegistration {
registered_at: registration.registered_at,
auth_key_hash: generate_auth_key_hash(token),
};
tracing::info!("Successfully retrieved registrations list");
Ok(EncryptedJson {
key_b16,
data: registration,
})
}
#[tracing::instrument(skip(state, app_handle), fields(auth_key))]
pub async fn verify_registration(
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
Json(confirmed_registration): Json<ConfirmedRegistrationRequest>,
) -> AgentResult<Json<AuthKeyResponse>> {
tracing::info!("Verifying registration request");
if !state
.validate_registration(&confirmed_registration.registration)
.await
{
tracing::warn!("Invalid registration attempt");
return Err(AgentError::InvalidRegistration);
}
let auth_key = Uuid::new_v4().to_string();
let created_at = Utc::now();
tracing::Span::current().record("auth_key", &auth_key.as_str());
let auth_key_copy = auth_key.clone();
let secret_key = EphemeralSecret::random();
let public_key = PublicKey::from(&secret_key);
let their_public_key = {
let public_key_slice: &[u8; 32] =
&base16::decode(&confirmed_registration.client_public_key_b16)
.map_err(|_| AgentError::InvalidClientPublicKey)?[0..32]
.try_into()
.map_err(|_| AgentError::InvalidClientPublicKey)?;
PublicKey::from(public_key_slice.to_owned())
};
let shared_secret = secret_key.diffie_hellman(&their_public_key);
if let Err(e) = state.update_registrations(app_handle.clone(), |regs| {
regs.insert(
auth_key_copy,
Registration {
registered_at: created_at,
shared_secret_b16: base16::encode_lower(shared_secret.as_bytes()),
},
);
}) {
tracing::error!("Failed to update registrations: {:?}", e);
return Err(e);
}
let auth_payload = json!({
"auth_key": auth_key,
"created_at": created_at
});
if let Err(e) = app_handle.emit("authenticated", &auth_payload) {
tracing::error!("Failed to emit authenticated event: {:?}", e);
return Err(AgentError::InternalServerError);
}
let _ = state.clear_active_registration().await;
tracing::info!("Registration verified successfully");
Ok(Json(AuthKeyResponse {
auth_key,
created_at,
agent_public_key_b16: base16::encode_lower(public_key.as_bytes()),
}))
}
#[tracing::instrument(skip(state, app_handle), fields(auth_key = %auth_key))]
pub async fn delete_registration(
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Path(auth_key): Path<String>,
) -> AgentResult<Json<serde_json::Value>> {
if !state.validate_access(auth_header.token()) {
tracing::warn!("Unauthorized deletion attempt");
return Err(AgentError::Unauthorized);
}
let _removed = state.update_registrations(app_handle.clone(), |regs| {
regs.remove(&auth_key);
})?;
tracing::info!("Registration deleted successfully");
let message = format!("{} registration deleted successfully", auth_key);
Ok(Json(json!({ "message": message })))
}
#[tracing::instrument(skip(state, body, _app_handle), fields(req_id))]
pub async fn execute(
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
headers: HeaderMap,
body: Bytes,
) -> AgentResult<EncryptedJson<relay::Response>> {
let nonce = match headers.get(NONCE) {
Some(n) => match n.to_str() {
Ok(n) => n,
Err(_) => {
tracing::warn!("Invalid nonce header");
return Err(AgentError::Unauthorized);
}
},
None => {
tracing::warn!("Missing nonce header");
return Err(AgentError::Unauthorized);
}
};
let request = match state.validate_access_and_get_data::<relay::Request>(
auth_header.token(),
nonce,
&body,
) {
Some(r) => r,
None => {
tracing::warn!("Invalid access or data");
return Err(AgentError::Unauthorized);
}
};
let request_id = request.id;
tracing::Span::current().record("request_id", &request_id);
let reg_info = match state.get_registration(auth_header.token()) {
Some(r) => r,
None => {
tracing::warn!("Registration info not found");
return Err(AgentError::Unauthorized);
}
};
Ok(relay::execute(request)
.await
.map(|response| EncryptedJson {
key_b16: reg_info.shared_secret_b16,
data: response,
})?)
}
/// Provides a way for registered clients to check if their
/// registration still holds, this route is supposed to return
/// an encrypted `true` value if the given auth_key is good.
/// Since its encrypted with the shared secret established during
/// registration, the client also needs the shared secret to verify
/// if the read fails, or the auth_key didn't validate and this route returns
/// undefined, we can count on the registration not being valid anymore.
#[tracing::instrument(skip(state, _app_handle))]
pub async fn registered_handshake(
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
) -> AgentResult<EncryptedJson<serde_json::Value>> {
let reg_info = state.get_registration(auth_header.token());
match reg_info {
Some(reg) => {
tracing::info!("Handshake successful");
Ok(EncryptedJson {
key_b16: reg.shared_secret_b16,
data: json!(true),
})
}
None => {
tracing::warn!("Unauthorized handshake attempt");
Err(AgentError::Unauthorized)
}
}
}
#[tracing::instrument(skip(state, _app_handle), fields(request_id = %request_id))]
pub async fn cancel(
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Path(request_id): Path<usize>,
) -> AgentResult<Json<serde_json::Value>> {
if !state.validate_access(auth_header.token()) {
tracing::warn!("Unauthorized cancellation attempt");
return Err(AgentError::Unauthorized);
}
if let Ok(()) = relay::cancel(request_id.try_into().unwrap()).await {
tracing::info!("Request cancelled successfully");
Ok(Json(json!({"message": "Request cancelled successfully"})))
} else {
tracing::warn!("Request not found");
Err(AgentError::RequestNotFound)
}
}
#[tracing::instrument(skip_all)]
pub async fn log_sink(
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
headers: HeaderMap,
body: Bytes,
) -> AgentResult<Json<serde_json::Value>> {
if !state.validate_access(auth_header.token()) {
tracing::warn!("Unauthorized log sink access attempt");
return Err(AgentError::Unauthorized);
}
let nonce = match headers.get(NONCE) {
Some(n) => match n.to_str() {
Ok(n) => n,
Err(_) => {
tracing::warn!("Invalid nonce header");
return Err(AgentError::Unauthorized);
}
},
None => {
tracing::warn!("Missing nonce header");
return Err(AgentError::Unauthorized);
}
};
let log_entry: LogEntry =
match state.validate_access_and_get_data(auth_header.token(), nonce, &body) {
Some(entry) => entry,
None => {
tracing::warn!("Failed to decrypt or parse log entry");
return Err(AgentError::BadRequest("Invalid log entry format".into()));
}
};
let metadata_str = log_entry
.metadata
.map(|m| m.to_string())
.unwrap_or_default();
let correlation = log_entry.correlation_id.unwrap_or_default();
match log_entry.level {
LogLevel::Debug => {
tracing::debug!(
timestamp = %log_entry.timestamp,
context = %log_entry.context,
source = %log_entry.source,
metadata = %metadata_str,
correlation_id = %correlation,
"{}",
log_entry.message
);
}
LogLevel::Info => {
tracing::info!(
timestamp = %log_entry.timestamp,
context = %log_entry.context,
source = %log_entry.source,
metadata = %metadata_str,
correlation_id = %correlation,
"{}",
log_entry.message
);
}
LogLevel::Warn => {
tracing::warn!(
timestamp = %log_entry.timestamp,
context = %log_entry.context,
source = %log_entry.source,
metadata = %metadata_str,
correlation_id = %correlation,
"{}",
log_entry.message
);
}
LogLevel::Error => {
tracing::error!(
timestamp = %log_entry.timestamp,
context = %log_entry.context,
source = %log_entry.source,
metadata = %metadata_str,
correlation_id = %correlation,
"{}",
log_entry.message
);
}
}
Ok(Json(json!({
"status": "success",
"message": "Log entry processed"
})))
}

View file

@ -1,58 +0,0 @@
use native_dialog::{MessageDialog, MessageType};
pub fn panic(msg: &str) {
const FATAL_ERROR: &str = "Fatal error";
MessageDialog::new()
.set_type(MessageType::Error)
.set_title(FATAL_ERROR)
.set_text(msg)
.show_alert()
.unwrap_or_default();
tracing::error!("{}: {}", FATAL_ERROR, msg);
panic!("{}: {}", FATAL_ERROR, msg);
}
pub fn info(msg: &str) {
tracing::info!("{}", msg);
MessageDialog::new()
.set_type(MessageType::Info)
.set_title("Info")
.set_text(msg)
.show_alert()
.unwrap_or_default();
}
pub fn warn(msg: &str) {
tracing::warn!("{}", msg);
MessageDialog::new()
.set_type(MessageType::Warning)
.set_title("Warning")
.set_text(msg)
.show_alert()
.unwrap_or_default();
}
pub fn error(msg: &str) {
tracing::error!("{}", msg);
MessageDialog::new()
.set_type(MessageType::Error)
.set_title("Error")
.set_text(msg)
.show_alert()
.unwrap_or_default();
}
pub fn confirm(title: &str, msg: &str, icon: MessageType) -> bool {
MessageDialog::new()
.set_type(icon)
.set_title(title)
.set_text(msg)
.show_confirm()
.unwrap_or_default()
}

View file

@ -1,99 +0,0 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AgentError {
#[error("FATAL: No `main` window found")]
NoMainWindow,
#[error("Tauri error: {0}")]
Tauri(#[from] tauri::Error),
#[error("Invalid Registration")]
InvalidRegistration,
#[error("Invalid Client Public Key")]
InvalidClientPublicKey,
#[error("Unauthorized")]
Unauthorized,
#[error("Request not found or already completed")]
RequestNotFound,
#[error("Internal server error")]
InternalServerError,
#[error("Invalid request: {0}")]
BadRequest(String),
#[error("Client certificate error")]
ClientCertError,
#[error("Root certificate error")]
RootCertError,
#[error("Invalid method")]
InvalidMethod,
#[error("Invalid URL")]
InvalidUrl,
#[error("Invalid headers")]
InvalidHeaders,
#[error("Request run error: {0}")]
RequestRunError(String),
#[error("Request cancelled")]
RequestCancelled,
#[error("Failed to clear registrations")]
RegistrationClearError,
#[error("Failed to insert registrations")]
RegistrationInsertError,
#[error("Failed to save registrations to store")]
RegistrationSaveError,
#[error("Serde error: {0}")]
Serde(#[from] serde_json::Error),
#[error("Store error: {0}")]
TauriPluginStore(#[from] tauri_plugin_store::Error),
#[error("Relay error: {0}")]
Relay(#[from] relay::error::RelayError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Log init error: {0}")]
LogInit(String),
#[error("Log init global error: {0}")]
LogInitGlobal(#[from] tracing::subscriber::SetGlobalDefaultError),
}
impl From<tracing_appender::rolling::InitError> for AgentError {
fn from(err: tracing_appender::rolling::InitError) -> Self {
AgentError::LogInit(err.to_string())
}
}
impl IntoResponse for AgentError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AgentError::InvalidRegistration => (StatusCode::BAD_REQUEST, self.to_string()),
AgentError::InvalidClientPublicKey => (StatusCode::BAD_REQUEST, self.to_string()),
AgentError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
AgentError::RequestNotFound => (StatusCode::NOT_FOUND, self.to_string()),
AgentError::InternalServerError => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
AgentError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AgentError::ClientCertError => (StatusCode::BAD_REQUEST, self.to_string()),
AgentError::RootCertError => (StatusCode::BAD_REQUEST, self.to_string()),
AgentError::InvalidMethod => (StatusCode::BAD_REQUEST, self.to_string()),
AgentError::InvalidUrl => (StatusCode::BAD_REQUEST, self.to_string()),
AgentError::InvalidHeaders => (StatusCode::BAD_REQUEST, self.to_string()),
AgentError::RequestRunError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
AgentError::RequestCancelled => (StatusCode::BAD_REQUEST, self.to_string()),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error".to_string(),
),
};
let body = Json(json!({
"error": error_message,
}));
(status, body).into_response()
}
}
pub type AgentResult<T> = std::result::Result<T, AgentError>;

View file

@ -1,3 +0,0 @@
pub const AGENT_STORE: &str = "app_data.bin";
pub const REGISTRATIONS: &str = "registrations";
pub const NONCE: &str = "X-Hopp-Nonce";

View file

@ -1,260 +0,0 @@
pub mod command;
pub mod controller;
pub mod dialog;
pub mod error;
pub mod global;
pub mod logger;
pub mod model;
pub mod route;
pub mod server;
pub mod state;
pub mod tray;
pub mod updater;
pub mod util;
pub mod webview;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Listener, Manager, WebviewWindowBuilder};
use tauri_plugin_updater::UpdaterExt;
use tokio_util::sync::CancellationToken;
use error::{AgentError, AgentResult};
use model::Payload;
use state::AppState;
pub const HOPPSCOTCH_AGENT_IDENTIFIER: &str = "io.hoppscotch.agent";
#[tracing::instrument(skip(app_handle))]
fn create_main_window(app_handle: &AppHandle) -> AgentResult<()> {
tracing::info!("Creating main application window");
let main = &app_handle
.config()
.app
.windows
.first()
.ok_or(AgentError::NoMainWindow)?;
tracing::debug!("Building webview window from config");
let window = WebviewWindowBuilder::from_config(app_handle, main)?.build()?;
window.hide()?;
tracing::info!("Main window created successfully");
Ok(())
}
#[tracing::instrument(skip(app_handle))]
pub fn show_main_window(app_handle: &AppHandle) -> AgentResult<()> {
tracing::debug!("Attempting to show main window");
if let Some(window) = app_handle.get_webview_window("main") {
window.show()?;
window.set_focus()?;
tracing::info!("Main window shown and focused");
}
Ok(())
}
#[tracing::instrument(skip(app_handle))]
pub fn hide_main_window(app_handle: &AppHandle) -> AgentResult<()> {
tracing::debug!("Attempting to hide main window");
if let Some(window) = app_handle.get_webview_window("main") {
window.hide()?;
tracing::info!("Main window hidden");
}
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tracing::info!("Initializing Hoppscotch Agent");
// The installer takes care of installing `WebView`,
// this check is only required for portable variant.
#[cfg(all(feature = "portable", windows))]
{
tracing::debug!("Checking WebView initialization for portable Windows variant");
webview::init_webview();
}
let cancellation_token = CancellationToken::new();
let server_cancellation_token = cancellation_token.clone();
tracing::debug!("Building Tauri application");
let builder = tauri::Builder::default()
// NOTE: Currently, plugins run in the order they were added in to the builder,
// so `tauri_plugin_single_instance` needs to be registered first.
// See: https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/single-instance
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
tracing::info!(
app_name = %app.package_info().name,
"Single instance handler triggered"
);
if let Err(e) = app.emit("single-instance", Payload::new(args, cwd)) {
tracing::error!(error = %e, "Failed to emit single-instance event");
}
// Application is already running, bring it to foreground.
if let Err(e) = show_main_window(&app) {
tracing::error!(error = %e, "Failed to show window");
}
}))
.plugin(tauri_plugin_store::Builder::new().build())
.setup(move |app| {
tracing::info!("Setting up application");
let app_handle = app.handle();
#[cfg(all(desktop, not(feature = "portable")))]
{
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_autostart::ManagerExt;
tracing::debug!("Configuring autostart for desktop variant");
let _ = app.handle().plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
None,
));
let autostart_manager = app.autolaunch();
tracing::info!(
enabled = autostart_manager.is_enabled().unwrap_or(false),
"Checking autostart status"
);
if !autostart_manager.is_enabled().unwrap_or(false) {
if let Err(e) = autostart_manager.enable() {
tracing::error!(error = %e, "Failed to enable autostart");
} else {
tracing::info!("Autostart enabled successfully");
}
}
};
#[cfg(desktop)]
{
tracing::debug!("Initializing desktop-specific features");
let _ = app
.handle()
.plugin(tauri_plugin_updater::Builder::new().build());
let _ = app.handle().plugin(tauri_plugin_dialog::init());
let updater = app.updater_builder().build().unwrap();
let app_handle_ref = app_handle.clone();
tauri::async_runtime::spawn_blocking(|| {
tauri::async_runtime::block_on(async {
updater::check_and_install_updates(app_handle_ref, updater).await;
})
});
};
// Create and hide the main window during setup.
create_main_window(&app_handle)?;
tracing::debug!("Initializing application state");
let app_state = Arc::new(AppState::new(app_handle.clone())?);
app.manage(app_state.clone());
let server_cancellation_token = server_cancellation_token.clone();
let server_app_handle = app_handle.clone();
tracing::debug!("Spawning server process");
tauri::async_runtime::spawn(async move {
server::run_server(app_state, server_cancellation_token, server_app_handle).await;
});
#[cfg(all(desktop))]
{
tracing::debug!("Creating system tray");
let handle = app.handle();
tray::create_tray(handle)?;
}
// Blocks the app from populating the macOS dock
#[cfg(target_os = "macos")]
{
tracing::debug!("Setting macOS activation policy");
app_handle
.set_activation_policy(tauri::ActivationPolicy::Accessory)
.unwrap();
};
let app_handle_ref = app_handle.clone();
app_handle.listen("maximize-window", move |_| {
tracing::info!("Maximize window event triggered");
if let Some(window) = app_handle_ref.get_webview_window("main") {
if let Err(e) = window.emit("show-otp-view", ()) {
tracing::error!("Failed to emit show-otp-view event: {}", e);
}
if let Err(e) = show_main_window(&app_handle_ref) {
tracing::error!("Failed to maximize window: {}", e);
}
}
});
let app_handle_ref = app_handle.clone();
app_handle.listen("registration-received", move |_| {
tracing::info!("Registration received event triggered");
if let Err(e) = show_main_window(&app_handle_ref) {
tracing::error!(error = %e, "Failed to show window");
}
});
tracing::info!("Application setup completed successfully");
Ok(())
})
.manage(cancellation_token)
.on_window_event(|window, event| {
match &event {
tauri::WindowEvent::CloseRequested { api, .. } => {
tracing::info!("Window close requested");
api.prevent_close();
if let Err(e) = window.hide() {
tracing::error!(error = %e, "Failed to hide window");
}
let app_state = window.state::<Arc<AppState>>();
let mut current_code = app_state.active_registration_code.blocking_write();
if current_code.is_some() {
tracing::debug!("Clearing active registration code");
*current_code = None;
}
if let Err(e) = window.emit("window-hidden", ()) {
tracing::error!(error = %e, "Failed to emit window-hidden event");
}
}
_ => {
tracing::debug!(event = ?event, "Window event received");
}
};
})
.invoke_handler(tauri::generate_handler![
command::get_otp,
command::list_registrations
]);
tracing::info!("Building Tauri application with context");
let app = builder
.build(tauri::generate_context!())
.expect("error while building tauri application");
tracing::info!("Running application");
app.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { api, code, .. } => {
if code.is_none() || matches!(code, Some(0)) {
tracing::info!("Exit requested, preventing immediate exit");
api.prevent_exit();
} else if code.is_some() {
tracing::info!("Exit with non-zero code requested, initiating shutdown");
let state = app_handle.state::<CancellationToken>();
state.cancel();
}
}
_ => {}
});
}

View file

@ -1,53 +0,0 @@
use std::path::PathBuf;
use file_rotate::{compression::Compression, suffix::AppendCount, ContentLimit, FileRotate};
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use crate::HOPPSCOTCH_AGENT_IDENTIFIER;
pub struct LogGuard(pub tracing_appender::non_blocking::WorkerGuard);
pub fn setup(log_dir: &PathBuf) -> Result<LogGuard, Box<dyn std::error::Error>> {
std::fs::create_dir_all(log_dir)?;
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into());
let log_file_path = log_dir.join(&format!("{}.log", HOPPSCOTCH_AGENT_IDENTIFIER));
tracing::info!(log_file_path =? &log_file_path);
let file = FileRotate::new(
&log_file_path,
AppendCount::new(5),
ContentLimit::Bytes(10 * 1024 * 1024),
Compression::None,
None,
);
let (non_blocking, guard) = tracing_appender::non_blocking(file);
let console_layer = fmt::layer()
.with_writer(std::io::stdout)
.with_thread_ids(true)
.with_thread_names(true)
.with_ansi(!cfg!(target_os = "windows"));
let file_layer = fmt::layer()
.with_writer(non_blocking)
.with_ansi(false)
.with_thread_ids(true)
.with_thread_names(true)
.with_timer(tracing_subscriber::fmt::time::UtcTime::rfc_3339());
tracing_subscriber::registry()
.with(env_filter)
.with(file_layer)
.with(console_layer)
.init();
tracing::info!(
log_file = %log_file_path.display(),
"Logging initialized with rotating file"
);
Ok(LogGuard(guard))
}

View file

@ -1,48 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use hoppscotch_agent_lib::{
logger::{self, LogGuard},
HOPPSCOTCH_AGENT_IDENTIFIER,
};
fn main() {
// Follows how `tauri` does this and exactly matches desktop's approach
// see: https://github.com/tauri-apps/tauri/blob/dev/crates/tauri/src/path/desktop.rs
let path = {
#[cfg(target_os = "macos")]
let path =
dirs::home_dir().map(|dir| dir.join("Library/Logs").join(HOPPSCOTCH_AGENT_IDENTIFIER));
#[cfg(not(target_os = "macos"))]
let path =
dirs::data_local_dir().map(|dir| dir.join(HOPPSCOTCH_AGENT_IDENTIFIER).join("logs"));
path
};
let Some(log_file_path) = path else {
eprint!("Failed to setup logging!");
println!("Starting Hoppscotch Agent...");
return hoppscotch_agent_lib::run();
};
let Ok(LogGuard(guard)) = logger::setup(&log_file_path) else {
eprint!("Failed to setup logging!");
println!("Starting Hoppscotch Agent...");
return hoppscotch_agent_lib::run();
};
// This keeps the guard alive, this is scoped to `main`
// so it can only drop when the entire app exits,
// so safe to have it like this.
let _guard = guard;
tracing::info!("Starting Hoppscotch Agent...");
hoppscotch_agent_lib::run()
}

View file

@ -1,110 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
/// Describes one registered app instance
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Registration {
pub registered_at: DateTime<Utc>,
/// base16 (lowercase) encoded shared secret that the client
/// and agent established during registration that is used
/// to encrypt traffic between them
pub shared_secret_b16: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MaskedRegistration {
pub registered_at: DateTime<Utc>,
pub auth_key_hash: String,
}
impl From<(&String, &Registration)> for MaskedRegistration {
fn from((key, registration): (&String, &Registration)) -> Self {
let hash = Sha256::digest(key.as_bytes());
let short_hash = base16::encode_lower(&hash[..3]);
Self {
registered_at: registration.registered_at,
auth_key_hash: short_hash,
}
}
}
#[derive(Debug, Serialize)]
pub struct RegistrationsList {
pub registrations: Vec<MaskedRegistration>,
pub total: usize,
}
/// Single instance payload.
#[derive(Clone, Serialize)]
pub struct Payload {
args: Vec<String>,
cwd: String,
}
impl Payload {
pub fn new(args: Vec<String>, cwd: String) -> Self {
Self { args, cwd }
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HandshakeResponse {
#[allow(non_snake_case)]
pub __hoppscotch__agent__: bool,
pub status: String,
pub agent_version: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfirmedRegistrationRequest {
pub registration: String,
/// base16 (lowercase) encoded public key shared by the client
/// to the agent so that the agent can establish a shared secret
/// which will be used to encrypt traffic between agent
/// and client after registration
pub client_public_key_b16: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthKeyResponse {
pub auth_key: String,
pub created_at: DateTime<Utc>,
/// base16 (lowercase) encoded public key shared by the
/// agent so that the client can establish a shared secret
/// which will be used to encrypt traffic between agent
/// and client after registration
pub agent_public_key_b16: String,
}
/// A logger guard, managed by tauri runtime to make sure
/// logger doesn't get cleaned up or dropped during app's run time.
pub struct LogGuard(pub tracing_appender::non_blocking::WorkerGuard);
#[derive(Debug, Deserialize)]
pub struct LogEntry {
pub timestamp: String,
pub level: LogLevel,
pub context: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub correlation_id: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "UPPERCASE")]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}

View file

@ -1,34 +0,0 @@
use axum::{
routing::{delete, get, post},
Router,
};
use std::sync::Arc;
use tauri::AppHandle;
use crate::{controller, state::AppState};
pub fn route(state: Arc<AppState>, app_handle: AppHandle) -> Router {
Router::new()
.route("/handshake", get(controller::handshake))
.route(
"/receive-registration",
post(controller::receive_registration),
)
.route(
"/verify-registration",
post(controller::verify_registration),
)
.route(
"/registered-handshake",
get(controller::registered_handshake),
)
.route("/registration", get(controller::registration))
.route(
"/registrations/:auth_key",
delete(controller::delete_registration),
)
.route("/execute", post(controller::execute))
.route("/cancel/:req_id", post(controller::cancel))
.route("/log-sink", post(controller::log_sink))
.with_state((state, app_handle))
}

View file

@ -1,50 +0,0 @@
use axum::Router;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tower_http::cors::CorsLayer;
use crate::route;
use crate::state::AppState;
#[tracing::instrument(skip(state, cancellation_token, app_handle))]
pub async fn run_server(
state: Arc<AppState>,
cancellation_token: CancellationToken,
app_handle: tauri::AppHandle,
) {
tracing::info!("Initializing server");
let cors = CorsLayer::permissive();
let app = Router::new()
.merge(route::route(state, app_handle))
.layer(cors);
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 9119));
tracing::info!(address = %addr, "Starting server");
match tokio::net::TcpListener::bind(&addr).await {
Ok(listener) => {
tracing::info!(address = %addr, "Server bound successfully");
if let Err(e) = axum::serve(listener, app.into_make_service())
.with_graceful_shutdown(async move {
cancellation_token.cancelled().await;
tracing::info!("Graceful shutdown initiated");
})
.await
{
tracing::error!(error = %e, "Server error occurred");
return;
}
tracing::info!("Server shut down successfully");
}
Err(e) => {
tracing::error!(
error = %e,
address = %addr,
"Failed to bind server to address"
);
}
}
}

View file

@ -1,277 +0,0 @@
use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit};
use axum::body::Bytes;
use dashmap::DashMap;
use serde::de::DeserializeOwned;
use tauri_plugin_store::StoreExt;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::{
error::{AgentError, AgentResult},
global::{AGENT_STORE, REGISTRATIONS},
model::Registration,
};
#[derive(Debug, Default)]
pub struct AppState {
/// The active registration code that is being registered.
pub active_registration_code: RwLock<Option<String>>,
/// Cancellation Tokens for the running requests
pub cancellation_tokens: DashMap<usize, CancellationToken>,
/// Registrations against the agent, the key is the auth
/// token associated to the registration
registrations: DashMap<String, Registration>,
}
impl AppState {
#[tracing::instrument(skip(app_handle))]
pub fn new(app_handle: tauri::AppHandle) -> AgentResult<Self> {
tracing::info!("Initializing application state");
let store = match app_handle.store(AGENT_STORE) {
Ok(store) => store,
Err(e) => {
tracing::error!("Failed to access app store: {}", e);
return Err(e.into());
}
};
// Try loading and parsing registrations from the store, if that failed,
// load the default list
let registrations = store
.get(REGISTRATIONS)
.and_then(|val| serde_json::from_value(val.clone()).ok())
.unwrap_or_else(|| {
tracing::debug!("No existing registrations found, initializing empty map");
DashMap::new()
});
// Try to save the latest registrations list
let _ = store.set(REGISTRATIONS, serde_json::to_value(&registrations)?);
if let Err(e) = store.save() {
tracing::error!("Failed to persist store changes: {}", e);
return Err(e.into());
}
tracing::info!("Application state initialized successfully");
Ok(Self {
active_registration_code: RwLock::new(None),
cancellation_tokens: DashMap::new(),
registrations,
})
}
/// Gets you a readonly reference to the registrations list
/// NOTE: Although DashMap API allows you to update the list from an immutable
/// reference, you shouldn't do it for registrations as `update_registrations`
/// performs save operation that needs to be done and should be used instead
#[tracing::instrument]
pub fn get_registrations(&self) -> &DashMap<String, Registration> {
tracing::debug!("Retrieving registrations list");
&self.registrations
}
/// Provides you an opportunity to update the registrations list
/// and also persists the data to the disk.
/// This function bypasses `store.reload()` to avoid issues from stale or inconsistent
/// data on disk. By relying solely on the in-memory `self.registrations`,
/// we make sure that updates are applied based on the most recent changes in memory.
#[tracing::instrument(skip(self, app_handle, update_func))]
pub fn update_registrations(
&self,
app_handle: tauri::AppHandle,
update_func: impl FnOnce(&DashMap<String, Registration>),
) -> Result<(), AgentError> {
tracing::info!("Updating registrations");
update_func(&self.registrations);
let store = match app_handle.store(AGENT_STORE) {
Ok(store) => store,
Err(e) => {
tracing::error!("Failed to access app store: {}", e);
return Err(e.into());
}
};
if store.has(REGISTRATIONS) {
tracing::debug!("Clearing existing registrations from store");
// We've confirmed `REGISTRATIONS` exists in the store
if !store.delete(REGISTRATIONS) {
tracing::error!("Failed to clear existing registrations");
return Err(AgentError::RegistrationClearError);
}
} else {
tracing::debug!("`REGISTRATIONS` key not found in store; continuing with update.");
}
// Since we've established `self.registrations` as the source of truth,
// we avoid reloading the store from disk and instead choose to override it.
match serde_json::to_value(self.registrations.clone()) {
Ok(value) => {
let _ = store.set(REGISTRATIONS, value);
}
Err(e) => {
tracing::error!("Failed to serialize registrations: {}", e);
return Err(e.into());
}
}
// Explicitly save the changes
if let Err(e) = store.save() {
tracing::error!("Failed to persist store changes: {}", e);
return Err(e.into());
}
tracing::info!("Registrations updated successfully");
Ok(())
}
/// Clear all the registrations
#[tracing::instrument(skip(self, app_handle))]
pub fn clear_registrations(&self, app_handle: tauri::AppHandle) -> Result<(), AgentError> {
tracing::info!("Clearing all registrations");
self.update_registrations(app_handle, |registrations| registrations.clear())?;
tracing::info!("All registrations cleared successfully");
Ok(())
}
#[tracing::instrument(skip(self))]
pub async fn clear_active_registration(&self) {
tracing::debug!("Clearing active registration code");
let mut active_registration_code = self.active_registration_code.write().await;
*active_registration_code = None;
tracing::debug!("Active registration code cleared");
}
#[tracing::instrument(skip(self))]
pub async fn validate_registration(&self, registration: &str) -> bool {
tracing::debug!("Validating registration code");
let is_valid = self.active_registration_code.read().await.as_deref() == Some(registration);
if is_valid {
tracing::info!("Registration code validated successfully");
} else {
tracing::warn!("Invalid registration code provided");
}
is_valid
}
#[tracing::instrument(skip(self))]
pub fn remove_cancellation_token(&self, req_id: usize) -> Option<(usize, CancellationToken)> {
tracing::debug!(req_id, "Removing cancellation token");
let result = self.cancellation_tokens.remove(&req_id);
if result.is_some() {
tracing::info!(req_id, "Cancellation token removed successfully");
} else {
tracing::debug!(req_id, "No cancellation token found to remove");
}
result
}
#[tracing::instrument(skip(self))]
pub fn add_cancellation_token(&self, req_id: usize, cancellation_token: CancellationToken) {
tracing::debug!(req_id, "Adding new cancellation token");
self.cancellation_tokens.insert(req_id, cancellation_token);
tracing::debug!(req_id, "Cancellation token added successfully");
}
#[tracing::instrument(skip(self))]
pub fn validate_access(&self, auth_key: &str) -> bool {
tracing::debug!(auth_key, "Validating access");
let is_valid = self.registrations.get(auth_key).is_some();
if is_valid {
tracing::info!(auth_key, "Access validated successfully");
} else {
tracing::warn!(auth_key, "Invalid access attempt");
}
is_valid
}
#[tracing::instrument(skip(self, data))]
pub fn validate_access_and_get_data<T>(
&self,
auth_key: &str,
nonce: &str,
data: &Bytes,
) -> Option<T>
where
T: DeserializeOwned,
{
tracing::debug!(
auth_key,
nonce_len = nonce.len(),
"Validating access and decrypting data"
);
let registration = match self.registrations.get(auth_key) {
Some(reg) => reg,
None => {
tracing::warn!(auth_key, "Registration not found");
return None;
}
};
let key: [u8; 32] = match base16::decode(&registration.shared_secret_b16).ok()?[0..32]
.try_into()
.ok()
{
Some(k) => k,
None => {
tracing::error!(auth_key, "Failed to decode shared secret");
return None;
}
};
let nonce: [u8; 12] = match base16::decode(nonce).ok()?[0..12].try_into().ok() {
Some(n) => n,
None => {
tracing::error!(auth_key, "Failed to decode nonce");
return None;
}
};
let cipher = Aes256Gcm::new(&key.into());
let data = data.iter().cloned().collect::<Vec<u8>>();
let plain_data = match cipher.decrypt(&nonce.into(), data.as_slice()) {
Ok(d) => d,
Err(e) => {
tracing::error!(auth_key, error = ?e, "Decryption failed");
return None;
}
};
match serde_json::from_reader(plain_data.as_slice()) {
Ok(result) => {
tracing::info!(auth_key, "Data successfully decrypted and parsed");
Some(result)
}
Err(e) => {
tracing::error!(auth_key, error = ?e, "Failed to parse decrypted data");
None
}
}
}
#[tracing::instrument(skip(self))]
pub fn get_registration(&self, auth_key: &str) -> Option<Registration> {
tracing::debug!(auth_key, "Retrieving registration tracing::info");
let result = self
.registrations
.get(auth_key)
.map(|reference| reference.value().clone());
if result.is_some() {
tracing::info!(
auth_key,
"Registration tracing::info retrieved successfully"
);
} else {
tracing::debug!(auth_key, "No registration tracing::info found");
}
result
}
}

View file

@ -1,126 +0,0 @@
use crate::{show_main_window, state::AppState};
use lazy_static::lazy_static;
use std::sync::Arc;
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Emitter, Manager,
};
const TRAY_ICON_DATA: &'static [u8] = include_bytes!("../icons/tray_icon.png");
lazy_static! {
static ref TRAY_ICON: Image<'static> = Image::from_bytes(TRAY_ICON_DATA).unwrap();
}
pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let clear_registrations = MenuItem::with_id(
app,
"clear_registrations",
"Clear Registrations",
true,
None::<&str>,
)?;
let maximize_window = MenuItem::with_id(
app,
"maximize_window",
"Maximize Window",
true,
None::<&str>,
)?;
let show_registrations = MenuItem::with_id(
app,
"show_registrations",
"Show Registrations",
true,
None::<&str>,
)?;
let pkg_info = app.package_info();
let app_name = pkg_info.name.clone();
let app_version = pkg_info.version.clone();
let app_name_item = MenuItem::with_id(app, "app_name", app_name, false, None::<&str>)?;
let app_version_item = MenuItem::with_id(
app,
"app_version",
format!("Version: {}", app_version),
false,
None::<&str>,
)?;
let menu = MenuBuilder::new(app)
.item(&app_name_item)
.item(&app_version_item)
.separator()
.item(&maximize_window)
.separator()
.item(&clear_registrations)
.item(&show_registrations)
.separator()
.separator()
.item(&quit_i)
.build()?;
let _ = TrayIconBuilder::with_id("hopp-tray")
.tooltip("Hoppscotch Agent")
.icon(if cfg!(target_os = "macos") {
TRAY_ICON.clone()
} else {
app.default_window_icon().unwrap().clone()
})
.icon_as_template(cfg!(target_os = "macos"))
.menu(&menu)
.show_menu_on_left_click(true)
.on_menu_event(move |app, event| match event.id.as_ref() {
"quit" => {
tracing::info!("Exiting the agent...");
// Exit with a specific code to allow actual exit.
app.exit(1);
}
"clear_registrations" => {
let app_state = app.state::<Arc<AppState>>();
app_state
.clear_registrations(app.clone())
.expect("Invariant violation: Failed to clear registrations");
}
"show_registrations" => {
app.emit("show-registrations", ()).unwrap_or_else(|e| {
tracing::error!("Failed to emit show-registrations event: {}", e);
});
if let Err(e) = show_main_window(&app) {
tracing::error!("Failed to show window: {}", e);
}
}
"maximize_window" => {
app.emit("maximize-window", ()).unwrap_or_else(|e| {
tracing::error!("Failed to emit maximize-window event: {}", e);
});
if let Err(e) = show_main_window(&app) {
tracing::error!("Failed to maximize window: {}", e);
}
}
_ => {
tracing::warn!("Unhandled menu event: {:?}", event.id);
}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Err(e) = show_main_window(&app) {
tracing::error!("Failed to show window from tray: {}", e);
}
}
})
.build(app);
Ok(())
}

View file

@ -1,67 +0,0 @@
use tauri::Manager;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_dialog::MessageDialogButtons;
use tauri_plugin_dialog::MessageDialogKind;
#[cfg(feature = "portable")]
use {crate::dialog, crate::util, native_dialog::MessageType};
pub async fn check_and_install_updates(
app: tauri::AppHandle,
updater: tauri_plugin_updater::Updater,
) {
let update = updater.check().await;
if let Ok(Some(update)) = update {
#[cfg(not(feature = "portable"))]
{
let do_update = app
.dialog()
.message(format!(
"Update to {} is available!{}",
update.version,
update
.body
.clone()
.map(|body| format!("\n\nRelease Notes: {}", body))
.unwrap_or("".into())
))
.title("Hoppscotch Agent Update Available")
.kind(MessageDialogKind::Info)
.buttons(MessageDialogButtons::OkCancelCustom(
"Update".to_string(),
"Cancel".to_string(),
))
.blocking_show();
if do_update {
let _ = update.download_and_install(|_, _| {}, || {}).await;
tauri::process::restart(&app.env());
}
}
#[cfg(feature = "portable")]
{
let download_url = "https://hoppscotch.com/download";
let message = format!(
"An update (version {}) is available for the Hoppscotch Agent.\n\nPlease download the latest portable version from our website.",
update.version
);
dialog::info(&message);
if dialog::confirm(
"Open Download Page",
"Would you like to open the download page in your browser?",
MessageType::Info,
) {
if let None = util::open_link(download_url) {
dialog::error(&format!(
"Failed to open download page. Please visit {}",
download_url
));
}
}
}
}
}

View file

@ -1,94 +0,0 @@
use std::process::{Command, Stdio};
use aes_gcm::{aead::Aead, AeadCore, Aes256Gcm, KeyInit};
use axum::{
body::Body,
response::{IntoResponse, Response},
};
use rand::rngs::OsRng;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::global::NONCE;
pub fn generate_auth_key_hash(auth_key: &str) -> String {
let hash = Sha256::digest(auth_key.as_bytes());
base16::encode_lower(&hash[..3])
}
pub fn open_link(link: &str) -> Option<()> {
let null = Stdio::null();
#[cfg(target_os = "windows")]
{
Command::new("rundll32")
.args(["url.dll,FileProtocolHandler", link])
.stdout(null)
.spawn()
.ok()
.map(|_| ())
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(link)
.stdout(null)
.spawn()
.ok()
.map(|_| ())
}
#[cfg(target_os = "linux")]
{
Command::new("xdg-open")
.arg(link)
.stdout(null)
.spawn()
.ok()
.map(|_| ())
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
None
}
}
#[derive(Debug)]
pub struct EncryptedJson<T: Serialize> {
pub key_b16: String,
pub data: T,
}
impl<T> IntoResponse for EncryptedJson<T>
where
T: Serialize,
{
fn into_response(self) -> Response {
let serialized_response = serde_json::to_vec(&self.data)
.expect("Failed serializing response to vec for encryption");
let key: [u8; 32] = base16::decode(&self.key_b16).unwrap()[0..32]
.try_into()
.unwrap();
let cipher = Aes256Gcm::new(&key.into());
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let nonce_b16 = base16::encode_lower(&nonce);
let encrypted_response = cipher
.encrypt(&nonce, serialized_response.as_slice())
.expect("Failed encrypting response");
let mut response = Response::new(Body::from(encrypted_response));
let response_headers = response.headers_mut();
response_headers.insert("Content-Type", "application/octet-stream".parse().unwrap());
response_headers.insert(NONCE, nonce_b16.parse().unwrap());
response
}
}

Some files were not shown because too many files have changed in this diff Show more