From 3303371afac6c5eb15e2c0229470f056c679ae53 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich <294273268+gitcommit90@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:03:28 +0000 Subject: [PATCH] Harden ReRouted for 0.4.2 launch --- .github/ISSUE_TEMPLATE/bug_report.yml | 91 +++++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 40 ++ .github/ISSUE_TEMPLATE/question.yml | 47 +++ .github/pull_request_template.md | 18 + .github/workflows/test.yml | 27 ++ CONTRIBUTING.md | 51 +++ PRIVACY.md | 62 +++ README.md | 29 +- SECURITY.md | 32 ++ docs/architecture.md | 20 +- docs/signing.md | 8 +- package-lock.json | 6 +- package.json | 6 +- scripts/serve-site.js | 7 +- site/index.html | 27 +- site/social-card.html | 2 +- site/social-card.png | Bin 105290 -> 105655 bytes src/lib/gateway.js | 6 +- src/lib/ipc-security.js | 67 ++++ src/lib/logger.js | 86 +++- src/lib/model-test.js | 26 +- src/lib/oauth.js | 220 ++++++---- src/lib/single-instance.js | 9 + src/lib/store.js | 73 +++- src/lib/usage.js | 445 +++++++++++++++------ src/main.js | 185 +++++---- src/preload.js | 61 ++- src/renderer/app.js | 109 +++-- src/renderer/index.html | 1 + src/renderer/lock-state.js | 35 ++ tests/gateway-key-disable.test.js | 40 ++ tests/ipc-security.test.js | 82 ++++ tests/logger-redaction.test.js | 117 ++++++ tests/model-error.test.js | 35 +- tests/oauth-security.test.js | 143 +++++++ tests/quota.test.js | 42 ++ tests/renderer-lock.test.js | 36 ++ tests/single-instance.test.js | 31 ++ tests/store-recovery.test.js | 76 ++++ tests/usage.test.js | 122 ++++++ 41 files changed, 2154 insertions(+), 374 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/question.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/test.yml create mode 100644 CONTRIBUTING.md create mode 100644 PRIVACY.md create mode 100644 SECURITY.md create mode 100644 src/lib/ipc-security.js create mode 100644 src/lib/single-instance.js create mode 100644 src/renderer/lock-state.js create mode 100644 tests/gateway-key-disable.test.js create mode 100644 tests/ipc-security.test.js create mode 100644 tests/logger-redaction.test.js create mode 100644 tests/oauth-security.test.js create mode 100644 tests/renderer-lock.test.js create mode 100644 tests/single-instance.test.js create mode 100644 tests/store-recovery.test.js create mode 100644 tests/usage.test.js diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..206fb4f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,91 @@ +name: Bug report +description: Report a reproducible ReRouted problem without exposing credentials or private data. +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for helping improve ReRouted. + + **Do not paste API keys, gateway keys, tokens, OAuth callback URLs or codes, cookies, raw `config.json`, email addresses, account IDs, private prompts, or full unreviewed logs.** Use placeholders and include only sanitized diagnostic excerpts. + + For a suspected vulnerability, follow [SECURITY.md](https://github.com/gitcommit90/rerouted/blob/main/SECURITY.md) instead of filing a public report. + - type: input + id: version + attributes: + label: ReRouted version + placeholder: "For example: 0.4.2" + validations: + required: true + - type: input + id: macos + attributes: + label: macOS version and Mac + placeholder: "macOS 15.5, M2 MacBook Air" + validations: + required: true + - type: input + id: client + attributes: + label: Client and request mode + description: Name the editor, agent, or script and whether the request was streaming. + placeholder: "Client name/version, streaming" + validations: + required: true + - type: input + id: provider + attributes: + label: Provider, authentication method, and model + description: Use generic account labels; do not include an email address or account ID. + placeholder: "Claude, OAuth, claude-sonnet model" + validations: + required: true + - type: dropdown + id: request_target + attributes: + label: Request target + options: + - Direct provider/model + - Named fallback route + - Named round-robin route + - Onboarding or account connection + - Application UI or settings + - Other + validations: + required: true + - type: textarea + id: problem + attributes: + label: What happened? + description: Describe the expected and actual behavior, including sanitized status codes or error text. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: List the smallest safe sequence that reproduces the problem. Replace credentials and private request content with placeholders. + placeholder: | + 1. Connect one provider using OAuth. + 2. Create a route with ... + 3. Send a sanitized request with ... + 4. Observe ... + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Sanitized diagnostics + description: Optional. Paste only the few relevant lines after reviewing and redacting them. + render: shell + - type: checkboxes + id: safety + attributes: + label: Safety check + options: + - label: I tested the latest stable release and searched existing issues. + required: true + - label: I removed credentials, OAuth data, account identifiers, email addresses, private prompts, and other sensitive information. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..767b5e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/gitcommit90/rerouted/blob/main/SECURITY.md + about: Read the private-reporting instructions before disclosing a vulnerability. + - name: Privacy information + url: https://github.com/gitcommit90/rerouted/blob/main/PRIVACY.md + about: Learn what ReRouted stores locally and how to remove its data. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..024c939 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,40 @@ +name: Feature request +description: Suggest a focused improvement to ReRouted. +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Describe the routing or application problem first, then the change you would like. + + **Do not include credentials, tokens, OAuth callback data, account identifiers, email addresses, private prompts, or unreviewed logs.** + - type: textarea + id: problem + attributes: + label: Problem to solve + description: What workflow is difficult or unavailable today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + description: Explain the smallest useful outcome rather than prescribing an implementation. + validations: + required: true + - type: textarea + id: context + attributes: + label: Relevant context + description: Include sanitized provider, client, or route details only when they clarify the request. + - type: checkboxes + id: safety + attributes: + label: Safety check + options: + - label: I searched existing issues for the same request. + required: true + - label: This request contains no credentials, OAuth data, account identifiers, email addresses, private prompts, or other sensitive information. + required: true diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..4fdeb44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,47 @@ +name: Question or support +description: Ask a focused usage or compatibility question without exposing private data. +title: "[Question]: " +labels: + - question +body: + - type: markdown + attributes: + value: | + Include enough sanitized context to understand the workflow. + + **Do not include credentials, tokens, OAuth callback data, account identifiers, email addresses, private prompts, or unreviewed logs.** + - type: input + id: version + attributes: + label: ReRouted version + placeholder: "For example: 0.4.2" + validations: + required: true + - type: input + id: environment + attributes: + label: macOS version and client + placeholder: "macOS 15.5, client name/version" + validations: + required: true + - type: textarea + id: question + attributes: + label: Question + description: Describe what you are trying to accomplish and where you are unsure. + validations: + required: true + - type: textarea + id: context + attributes: + label: Sanitized context + description: Optional provider, authentication method, route, or model details that clarify the question. + - type: checkboxes + id: safety + attributes: + label: Safety check + options: + - label: I searched existing issues and documentation first. + required: true + - label: This question contains no credentials, OAuth data, account identifiers, email addresses, private prompts, or other sensitive information. + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..0580536 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## External contributions are not yet accepted + +ReRouted is source-visible while its licensing approach is finalized. Please read [CONTRIBUTING.md](../CONTRIBUTING.md) before opening a pull request. External code and documentation contributions may be closed without review until a license and contribution process are published. + +Never include real API keys, gateway keys, OAuth tokens or codes, callback URLs, cookies, account identifiers, email addresses, private prompts, provider responses, or unreviewed logs. + +## Maintainer change + +Describe the user-visible problem and the resulting behavior. + +## Verification + +- [ ] Focused validation passes. +- [ ] `npm test` passes. +- [ ] `git diff --check` passes. +- [ ] UI changes include current captures where applicable. +- [ ] No credentials, private user data, or generated release artifacts are included. +- [ ] Release/version work is left to the maintainer release process. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..836f194 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Use Node.js 22.13 + uses: actions/setup-node@v4 + with: + node-version: 22.13.0 + cache: npm + - name: Install dependencies + run: npm ci + - name: Run tests + run: npm test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..640764b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing + +Thank you for helping improve ReRouted. + +## Current contribution status + +ReRouted is source-visible while its licensing approach is finalized. No software license has been selected, and external code contributions are not currently accepted. + +Please do not open a pull request with code or documentation changes yet. It may be closed without review. Focused issues, feature requests, compatibility reports, and sanitized reproduction cases are welcome. + +## Before opening an issue + +1. Install the latest stable release and check existing issues. +2. Confirm the behavior with the smallest route and request that reproduce it. +3. Record the ReRouted version, macOS version, provider type, authentication method, model, client, and whether the request streamed. +4. Replace account names, model identifiers, or request content when they are not essential to the report. + +Never attach `config.json` or paste full, unreviewed diagnostics. Remove API keys, gateway keys, tokens, OAuth callback URLs or codes, cookies, account IDs, email addresses, prompts, and private provider responses. + +Use the repository's bug or feature request form so maintainers receive the context needed to reproduce the report. + +## Reproduction cases + +Good reports distinguish between: + +- A response produced by the upstream provider and a response produced by ReRouted. +- A direct provider/model request and a named-route request. +- Streaming and non-streaming behavior. +- One connected account and an OAuth account pool. + +Include exact status codes and sanitized error text when available. Do not include credentials to make a reproduction executable. + +## Local verification + +These commands document the baseline used by maintainers and may help when investigating an issue locally: + +```bash +npm ci +npm test +git diff --check +``` + +Node.js 22.13 or newer is required. The packaged application currently targets Apple Silicon and macOS 12 Monterey or newer. + +Maintainers handle package version changes, signing, notarization, release publication, and installation verification. Reproduction branches should not include generated release artifacts or real provider credentials. + +## Security reports + +Do not use a public issue for a vulnerability or credential exposure. Follow [Security](./SECURITY.md) instead. + +This policy will be updated when a project license and an external pull-request process are in place. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..a2c2a5a --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,62 @@ +# Privacy + +ReRouted is a local macOS application. It has no ReRouted account, hosted control plane, or third-party product analytics service. + +This document describes the application as shipped. The upstream providers and clients you connect have their own privacy policies and data practices. + +## Website + +The public `rerouted.dev` website is a static site delivered through Cloudflare and loads its display fonts from Google Fonts. Those services may receive ordinary web-request metadata such as your IP address, browser headers, and requested asset URLs under their own privacy policies. The website does not include ReRouted product analytics or an account system. + +## Data stored on your Mac + +ReRouted stores application data in its macOS Application Support directory, normally `~/Library/Application Support/ReRouted`. Existing installations may use `~/Library/Application Support/rerouted`. + +Local data includes: + +- Provider settings, OAuth credentials, API keys, gateway keys, routes, and application preferences in `config.json`. +- Request metadata, provider and route selections, statuses, timestamps, and token counts in the uncapped `usage.sqlite` database. +- Gateway, OAuth, update, and routing diagnostics in `rerouted.log`. + +Prompt bodies are not intentionally persisted. Provider credentials and gateway keys are not encrypted at rest. ReRouted restricts local file permissions where macOS supports doing so, but anyone who can access your macOS user account or its files may be able to read them. + +Diagnostics can contain provider error text, model and route names, account identifiers, and OAuth metadata. Treat logs as sensitive and review every line before sharing an excerpt. + +## Local credential discovery + +Credential discovery happens when you choose to scan or import accounts. Depending on the providers installed on the Mac, ReRouted may inspect supported entries in the Codex configuration, the Claude Code macOS Keychain or supported local auth files, ReRouted auth-profile folders, and Antigravity-named JSON files in supported folders including `~/Downloads`. ReRouted summarizes discoveries before import; selected credentials are copied into its own configuration. + +At startup, ReRouted may also read the local `~/.grok/auth.json` file to attach a human-readable identity to an xAI account that is already connected. This startup lookup only updates local account labeling; it does not import a new account by itself. + +## Network activity + +ReRouted makes network requests only as needed to operate features you choose: + +- Completion requests, credentials, and supported image inputs are sent to the selected upstream provider. +- OAuth authorization and token refresh requests are sent to the relevant provider. +- Quota checks are sent to supported providers when you open the Quota page, every 60 seconds while that page remains open, or when you manually refresh it. +- Automatic update checks contact `update.electronjs.org` shortly after launch and about every six hours; signed update downloads come from GitHub Releases. + +The gateway binds to `127.0.0.1` by default. If you enable network access, it binds to `0.0.0.0`; devices that can reach the Mac can attempt to access it, and the gateway bearer key becomes the primary access boundary. + +## OAuth and subscription notice + +ReRouted is independent and is not affiliated with or endorsed by any upstream provider. This provider's subscription or OAuth session is not officially licensed for proxy or router use. Using it this way may result in account restrictions or bans. Proceed at your own risk, review the provider's current terms, and prefer a documented API-key integration when you need a stable production path. + +## Retention and deletion + +Usage history is not automatically pruned and remains on the Mac until the Application Support data is removed. Existing installations may also retain the former `usage.json` as a one-time migration backup. Logs can be cleared from the Activity diagnostics view. + +Uninstalling the application bundle does not automatically remove Application Support data. To remove ReRouted and its stored credentials completely: + +1. Quit ReRouted. +2. Delete `/Applications/ReRouted.app`. +3. Delete the ReRouted Application Support directory listed above. + +Deleting the Application Support directory permanently removes connected accounts, keys, routes, settings, usage history, and logs. Back up only the data you intentionally want to retain. + +## Sharing diagnostics + +Never post `config.json` or an unreviewed log file. Remove API keys, gateway keys, tokens, OAuth codes and callback URLs, cookies, email addresses, account identifiers, private provider responses, and prompt content before sharing a reproduction or log excerpt. + +See [Security](./SECURITY.md) for reporting a suspected vulnerability and [Contributing](./CONTRIBUTING.md) for safe bug-report guidance. diff --git a/README.md b/README.md index 484cf92..baa4bac 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Website | Download | Quick start | - Architecture + Architecture | + Security | + Privacy

GitHub release @@ -91,11 +93,15 @@ OAuth accounts and keyed providers can live in the same route. ReRouted handles ReRouted is an independent project and is not affiliated with or endorsed by any upstream provider. +> **OAuth notice:** This provider's subscription or OAuth session is not officially licensed for proxy or router use. Using it this way may result in account restrictions or bans. Proceed at your own risk. Provider behavior and policies can change without notice; API-key integrations are the more stable choice where available. + ## Quick start ### 1. Install -[Download ReRouted 0.4.1 for Apple Silicon](https://github.com/gitcommit90/rerouted/releases/download/v0.4.1/ReRouted-0.4.1-arm64.dmg), open the DMG, and drag ReRouted to Applications. +[Download ReRouted 0.4.2 for Apple Silicon](https://github.com/gitcommit90/rerouted/releases/download/v0.4.2/ReRouted-0.4.2-arm64.dmg), open the DMG, and drag ReRouted to Applications. + +ReRouted requires Apple Silicon and macOS 12 Monterey or newer. The macOS release is Developer ID signed, notarized by Apple, and stapled for a normal Gatekeeper launch. @@ -155,15 +161,26 @@ Requests require a generated bearer key except for `/` and `/health`. OpenAI-sty - The gateway binds to `127.0.0.1` by default. - Configuration, credentials, request metadata, usage, and logs are stored locally. +- Usage history is stored in an uncapped local SQLite database so all-time statistics do not silently discard older requests. - Prompt bodies are not intentionally persisted. - Local config and usage files are written with restrictive permissions where supported. - Provider credentials are not encrypted at rest. - Requests and the credentials needed to authorize them are sent to the upstream services you choose. - Enabling network access binds the gateway to `0.0.0.0`; only do that on a network you trust. +See [Privacy](./PRIVACY.md) for the local files ReRouted keeps, the network services it contacts, and how to remove its data. + +## Support, security, and project status + +- For questions, reproducible bugs, and feature requests, use [GitHub Issues](https://github.com/gitcommit90/rerouted/issues). +- For a suspected vulnerability, follow [the security policy](./SECURITY.md) and do not post credentials or sensitive details in a public issue. +- Before sharing diagnostics, remove API keys, gateway keys, OAuth callback URLs or codes, account identifiers, email addresses, and any provider response that may contain private data. + +ReRouted's source is public for review and local builds, but no software license has been selected yet. The repository is therefore source-visible, not offered as open source. External code contributions are not currently accepted while licensing is finalized; focused issues and sanitized reproduction reports are welcome. See [Contributing](./CONTRIBUTING.md) for the current policy. + ## Build from source -Requires Node.js 22.12 or newer. Packaging requires macOS and produces an Apple Silicon DMG. +Requires Node.js 22.13 or newer. Packaging requires macOS and produces an Apple Silicon DMG. ```bash git clone https://github.com/gitcommit90/rerouted.git @@ -181,13 +198,11 @@ npm run package:dmg The implementation is intentionally small: Electron, Node's built-in HTTP server, and a vanilla HTML/CSS/JavaScript renderer. See [the architecture document](./docs/architecture.md) for the runtime, routing, persistence, and packaging details. -Questions and bug reports are welcome in [GitHub Issues](https://github.com/gitcommit90/rerouted/issues). - ## Current release -ReRouted `0.4.1` ships for Apple Silicon macOS with masked OAuth account identities, image inputs in chat completions, a Developer ID signature, stapled Apple notarization tickets, and in-app updates backed by stable GitHub Releases. The public API is intentionally limited to health, model discovery, and chat completions; a published third-party client compatibility matrix is still forthcoming. +ReRouted `0.4.2` ships for Apple Silicon macOS with hardened OAuth callbacks and renderer lock boundaries, uncapped SQLite usage history, 60-second quota refreshes while the Quota page is open, masked account identities, and image inputs in chat completions. Public builds are Developer ID signed, notarized, stapled, and distributed through stable GitHub Releases with in-app updates. The public API is intentionally limited to health, model discovery, and chat completions; a published third-party client compatibility matrix is still forthcoming. -ReRouted is released by [Public Bytes](https://publicbytes.org), a nonprofit building practical technology for public good. +ReRouted is an independent personal project. ## Thanks diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b00ce9c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security Policy + +ReRouted handles OAuth sessions, API keys, gateway keys, and local routing data. Treat security reports and diagnostic material accordingly. + +## Supported versions + +Security fixes are provided for the latest stable release. Update through **Settings -> Software updates** or install the newest signed release before reporting a problem that may already be fixed. + +## Reporting a vulnerability + +Do not disclose a vulnerability, credential, or sensitive reproduction in a public issue. + +Use GitHub's private [Report a vulnerability](https://github.com/gitcommit90/rerouted/security/advisories/new) form. Private vulnerability reporting is enabled for this repository. Do not open a public placeholder issue or disclose report details in Discussions, pull requests, or logs. + +Include only the information needed to investigate: + +- ReRouted version and macOS version. +- The affected feature and realistic impact. +- Reproduction steps that use placeholder credentials and sanitized data. +- Whether the gateway was bound only to localhost or exposed to a network. + +Never include API keys, gateway keys, access or refresh tokens, OAuth authorization codes, callback URLs, cookies, raw `config.json`, full unreviewed logs, account identifiers, or private prompts. + +## Appropriate reports + +Examples include authentication bypasses, credential disclosure, unsafe network exposure, cross-account data leakage, update verification failures, or a way for untrusted local content to execute code in the application. + +Provider outages, expired subscriptions, quota behavior, unsupported models, and ordinary routing failures belong in a sanitized bug report unless they expose a security boundary. + +## Response expectations + +ReRouted is an independently maintained personal project. Reports are handled on a best-effort basis. Please allow time to confirm the issue and coordinate a fix before public disclosure. diff --git a/docs/architecture.md b/docs/architecture.md index d5ae859..92e921d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ ReRouted is one Electron process with three jobs: There is no separate daemon. Closing or hiding the panel does not stop the gateway; quitting ReRouted does. ```text -OpenAI-compatible client +OpenAI-style chat-completions client | | Bearer rr-... + /v1/chat/completions v @@ -29,14 +29,14 @@ src/lib/providers/* ---> upstream provider API | | normalize response/SSE v -OpenAI-compatible response +OpenAI-style chat-completions response ``` ## Main process and panel `src/main.js` owns the single-instance lock, tray, frameless panel window, login-item preference, IPC handlers, store, usage store, router, and gateway lifecycle. -The panel is a local file loaded from `src/renderer/index.html`. `src/preload.js` exposes an IPC bridge to `src/renderer/app.js`; context isolation is enabled and renderer Node integration is disabled. The BrowserWindow sandbox is currently disabled. +The panel is a local file loaded from `src/renderer/index.html`. `src/preload.js` exposes an allowlisted IPC bridge to `src/renderer/app.js`; context isolation and the renderer sandbox are enabled, and renderer Node integration is disabled. The renderer is vanilla HTML, CSS, and JavaScript. It renders onboarding and the Status, Accounts, Routes, Activity, and Settings pages from state returned by the main process. @@ -53,6 +53,8 @@ The renderer is vanilla HTML, CSS, and JavaScript. It renders onboarding and the The default bind is `127.0.0.1:4949`. Settings can switch the host to `0.0.0.0` for LAN or Tailscale access. CORS is currently `*`, so the bearer key is the gateway's access boundary when network binding is enabled. +JSON request bodies are limited to 32 MiB. Oversized requests receive a JSON `413` response before routing begins. + ## Model IDs and routes Provider model IDs are generated by `src/lib/providers/index.js`. A direct model resolves to one enabled provider/model pair. @@ -102,12 +104,14 @@ Electron's `userData` directory contains: | File | Contents | | --- | --- | | `config.json` | Providers, credentials, models, routes, gateway keys, bind settings, onboarding state, admin password hash | -| `usage.json` | Up to 20,000 recent request metadata rows and token counts | +| `usage.sqlite` | Uncapped local request metadata and token counts, indexed by timestamp for period and all-time statistics | | `rerouted.log` | Gateway, OAuth, and operational diagnostics | The Quota page probes subscription windows directly for ChatGPT/Codex, Claude, and Antigravity. Probe failures remain isolated per account and do not disable chat routing. -Config and usage writes use a temporary file followed by rename. The files are written with mode `0600`; parent directories are created with mode `0700` where supported. +Config writes use a temporary file followed by rename. Usage inserts use SQLite WAL mode with prepared statements. The primary files are written with mode `0600`; parent directories are created with mode `0700` where supported. + +On the first `0.4.2` launch, every row still present in the legacy `usage.json` file is imported transactionally into `usage.sqlite`. The legacy file remains as a migration backup, and a database marker prevents duplicate imports. New history is not automatically pruned. The admin password is scrypt-hashed. Provider credentials and gateway keys are not encrypted at rest. @@ -136,13 +140,13 @@ Packaged builds use Electron's native macOS updater and the public stable GitHub ## Tests and current gaps -`tests/gateway.test.js` covers password hashing, config persistence, bearer auth, model listing, streaming and non-streaming completion paths, fallback, round-robin ordering, timeouts, OAuth request behavior, token refresh, format translation, SSE decoding, multiple gateway keys, disabled models, and usage aggregation. +`tests/gateway.test.js` covers password hashing, config persistence, bearer auth, request-size enforcement, model listing, streaming and non-streaming completion paths, fallback, round-robin ordering, timeouts, OAuth request behavior, token refresh, format translation, SSE decoding, multiple gateway keys, disabled models, and usage aggregation. Important gaps to keep visible: - No automated renderer or end-to-end Electron tests. -- No macOS packaging test in CI. -- No request-body size limit or gateway rate limit. +- The Node test suite runs in GitHub Actions, but there is no macOS packaging test in CI. +- No gateway request-rate limit. - No automated release publication or CI-hosted signing/notarization. - No commit SHA embedded in the app bundle. - No compatibility matrix for third-party OpenAI clients. diff --git a/docs/signing.md b/docs/signing.md index c97e0b7..184d380 100644 --- a/docs/signing.md +++ b/docs/signing.md @@ -64,6 +64,8 @@ That command uses Developer ID signing when an identity is available. Without on The release script verifies each stage. These commands provide an independent check: ```bash +VERSION="$(node -p 'require("./package.json").version')" + codesign --verify --deep --strict --verbose=2 \ dist/ReRouted-darwin-arm64/ReRouted.app @@ -71,12 +73,12 @@ xcrun stapler validate dist/ReRouted-darwin-arm64/ReRouted.app spctl --assess --type execute --verbose=4 \ dist/ReRouted-darwin-arm64/ReRouted.app -xcrun stapler validate dist/ReRouted-0.3.1-arm64.dmg +xcrun stapler validate "dist/ReRouted-${VERSION}-arm64.dmg" spctl --assess --type open --context context:primary-signature --verbose=4 \ - dist/ReRouted-0.3.1-arm64.dmg + "dist/ReRouted-${VERSION}-arm64.dmg" UPDATE_DIR="$(mktemp -d)" -ditto -x -k dist/ReRouted-0.3.1-mac-arm64.zip "$UPDATE_DIR" +ditto -x -k "dist/ReRouted-${VERSION}-mac-arm64.zip" "$UPDATE_DIR" codesign --verify --deep --strict --verbose=2 "$UPDATE_DIR/ReRouted.app" xcrun stapler validate "$UPDATE_DIR/ReRouted.app" spctl --assess --type execute --verbose=4 "$UPDATE_DIR/ReRouted.app" diff --git a/package-lock.json b/package-lock.json index ed379e9..6b83c59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "rerouted", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rerouted", - "version": "0.4.1", + "version": "0.4.2", "devDependencies": { "@electron/osx-sign": "1.3.3", "@electron/packager": "18.4.4", "electron": "43.1.0" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.13.0" } }, "node_modules/@electron-internal/extract-zip": { diff --git a/package.json b/package.json index 546db19..85bb5ac 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "rerouted", - "version": "0.4.1", + "version": "0.4.2", "description": "A macOS menu-bar router for accounts, models, and automatic fallback.", - "author": "Public Bytes", + "author": "gitcommit90", "homepage": "https://rerouted.dev", "repository": { "type": "git", @@ -31,6 +31,6 @@ "electron": "43.1.0" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.13.0" } } diff --git a/scripts/serve-site.js b/scripts/serve-site.js index 2c89e8b..1d60edc 100644 --- a/scripts/serve-site.js +++ b/scripts/serve-site.js @@ -6,7 +6,7 @@ const http = require("node:http"); const fs = require("node:fs"); const path = require("node:path"); -const ROOT = path.resolve(__dirname, "..", "site"); +const ROOT = path.resolve(process.env.SITE_ROOT || path.join(__dirname, "..", "site")); const PORT = Number(process.env.PORT || 8099); const HOST = process.env.HOST || "127.0.0.1"; @@ -40,8 +40,9 @@ const server = http.createServer((req, res) => { const urlPath = decodeURIComponent((req.url || "/").split("?")[0]); let rel = urlPath === "/" ? "/index.html" : urlPath; // resolve inside ROOT only — block traversal - const abs = path.normalize(path.join(ROOT, rel)); - if (!abs.startsWith(ROOT)) { + const abs = path.resolve(ROOT, `.${rel}`); + const relative = path.relative(ROOT, abs); + if (relative.startsWith("..") || path.isAbsolute(relative)) { res.writeHead(403).end("forbidden"); return; } diff --git a/site/index.html b/site/index.html index 27ec496..cef9184 100644 --- a/site/index.html +++ b/site/index.html @@ -16,7 +16,7 @@ - + @@ -24,7 +24,7 @@ - + @@ -690,7 +690,7 @@ .foot-inner { display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; } .foot-copy { font-size: 13.5px; color: var(--muted); } .foot-copy b { color: var(--ink-soft); font-weight: 600; } - .foot-links { display: flex; gap: 24px; font-size: 13.5px; color: var(--muted); } + .foot-links { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13.5px; color: var(--muted); } .foot-links a:hover { color: var(--route-dark); } .foot-mono { font-family: var(--mono); font-size: 12px; color: var(--faint); } @@ -741,7 +741,7 @@ Local gateway · macOS menu bar

One endpoint.
Your run keeps going.

- Point any configurable chat-completions client at one local URL. + Point a compatible configurable chat-completions client at one local URL. When a provider rate-limits, times out, or fails before output begins, ReRouted moves to the next route member — your endpoint and route stay the same.

@@ -907,7 +907,10 @@

Bring the accounts you already pay for.
GLM Coding
API key
api key

- …plus any custom OpenAI-compatible service — just a base URL and a key. + …plus custom upstreams that expose the supported OpenAI-style chat-completions shape. +

+

+ Notice: This provider's subscription or OAuth session is not officially licensed for proxy or router use. Using it this way may result in account restrictions or bans. Proceed at your own risk. ReRouted is independent and is not affiliated with or endorsed by upstream providers.

@@ -966,15 +969,15 @@

Connect once. Route on purpose.
Stop ba
- ReRouted — a local OpenAI-compatible LLM gateway.
- v0.3.1 · Apple Silicon · Developer ID signed · Apple notarized · In-app updates
- Released by Public Bytes, a nonprofit building practical technology for public good. + ReRouted — a local OpenAI-style chat-completions gateway.
+ Current stable release · macOS 12+ · Apple Silicon · Developer ID signed · Apple notarized
+ An independent personal project.
diff --git a/site/social-card.html b/site/social-card.html index 42ae973..76e5a8f 100644 --- a/site/social-card.html +++ b/site/social-card.html @@ -119,7 +119,7 @@

One endpoint.
Your run keeps going.

Local routing for AI tools. No cloud control plane. No Dock icon. - 0.3.1 / Apple Silicon / local-first + macOS 12+ / Apple Silicon / local-first
diff --git a/site/social-card.png b/site/social-card.png index 665fa50e4147afd8fb6085047c92c77d1b72d48a..58c9236f4c596f1fb85484301663dd9972be24a9 100644 GIT binary patch delta 54519 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�z(;e$BIb?% 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ΧW-IwY?A5G8hmmz&P^4D9F4ihj zANDH5h$EpWO)yM`wI(u6F}eF7V)ugz8^Db?+-PIHaN_pIauQFCGwSQ$m)^Pje<$(T z%|RnEec<8&?Arvd4*K-+az~L&xb15k5hCR&ikJCE@@_v3UXG>{npu7f?Hm@74ISPy z^rY1x_28g6Z(BA_ICiCdsxxYz=WJ;By(C?3bl#S0X|qyych~g>leP{%1%h|LpPh*P zG0%%b>L}vzCBqYFeSJ%h{u3VAKRCT$))arH>UV)Q(0KGwxg?Ugn^smQ2t*7xQyV9z z1`k3EaulmKr00GnoUVTs`V`}IDPo|;Tmihz%sDNeK|b>;200uRO#f_#);k{~r)qJX z*@v}#{>`%#`F#DfTwjlDjAjTx9nPrmU6-}De>gjuW8Y_vCJ-FGr4nd8$ml;=Y?~O} z3X?8)yxlj5kQsjV@ddAA@gO~TWAV(+Tgt5Sd<-TSvw&)5ja)8F_`D!e6)NcW*e}p} zD3j*ENGruP9X=knLw!d)>xIXrU-+}xqmG9>fN{lLFFJxNf}`!4!5x?#+r(s}pig`0 z50Z$^^-!hyZN zQP8!ehtG?1zG`tQ`$*$%L4IE2Aim4&M%wEW*vbATi9`)>dpd+6f2wgv;h~qqKJsv$&Qx{D z$xZmT63;HmX9(Q&>>E7Zdmzb4dPWfxE|`Ek^YGv(5nDANFeXgWtap5UZ%~A?N!0tmYKKbIzq;zfl#Ddb*bBMyv*7abcN;2!TJV%l zP6qD!=J_jdOzI~H(vaysC9+BTtvcx8C*yyAky&|PZ#SA! z>2B1z-`x|X(`ur_x?UEuP%Osr*qyi=jy9)Et`k6_JL#$RPR~dp&s>-)jLG@yZN$hE6?v7+k?w-_2%bT#8T$K0+`H8`!l{V_Efw0mF?Sg<-EVUU*Z5h3V zQ|~*=qP4cn$4i}aP#?uF6E}kf?w_i3YdzMw2(w*<7%8set2$XLog5wgPF}sorqNCo z%=_jm6bHsSJTd8x%{sVXAbTk8HlA)2y8K0|a%(Tc3;_1Zz&vkS}E%jMqZHSf-HL{VE^T~!&l+(i?@*ANN0Ig}(V z>^0=_06Anp>q!GdMen+v8;yqoQ3X3e6OT~&1M!bFV8W^Ovb@YTmcy(PE9zDL^788G z_Uv(wC6YBK4&Zeh*dYY|Rzt45G+LiAae5RCHHBLIV*CVx&yt-p$H$>D>F{nCoTia85`wpZmQg?Ta zBRg8o$O9-bt@SYY(;+-siDUzbB9Kry*y$LQ8q!PY084as}=C)d=xZcnkokr1uVT(k7j)_*lbI-;~yY)1sobUe89dbtUMmyV*vI@M@Rc8Mv!gTz%FG`zSTEajONzVr(74n z*_Bwpr#UQ*u>QEOGKU0BJ){TfNyy_4afkoV`t=zkje=D1f-lB9o+dK8IE+SVtOrmV zP8WlrV>{8xHGi8iLDS3~Z1t^LnxZ3wP=uoTS?sP6TVmqfyC1yk6{Eqz!NLj1pnLFI zPF&QS%pybW6C-3up`J*8dTu+9iXa1`x_?6^s&XqacRq}dJ^RoJQgSk~^O+C&zk>hb zrrp|U&d~3lKs>8pUBlyaIXlr+mA7rQvId8Gdon*Z(?@)C?GV5V3tP%b5TYx!hNtLg zzzdvtEb|2Ph4BSvBFmC0{_sv>!Z-YR8?@LC7Q8=4+x$L%g*$~6H4NYgXaRP5I5(Gt z$dU{(Q6}9^NM%gAc|!=o#ip3ulyeMx2uzpD$y7{&son>f%e)@1kX`eSlAU_H?Da{ zomiO1ALD{ULPc4#fK!w_!c1@~0t?QkX81ebmaJI*Dql<%VOQ>N8k)X;}FSSyEal2%6{a zSt+)t!jPb&$mkM*^=FUAXPD-1?~ROZNz7J?^yFpFPfsDLP+e$($iDRj&_U1I($N!t zvaf!h{&W4KqooLe<-qFb?6?>nx^%1#G{041kDBr|{$#fCV1Yx^0B*+L^etUH zth{$od7v7-o3#HUG7|J%zq<)2{NQpyWC9r_uk;Tr0sc#tWJfIgHx$f(?s>6ve})~8 z2#GVk`fqG6q-1n8$i}g5@1Li|Wy%rBd1w}$YXB09NTN|GWV4!xqH+|0iek9I1agEJ z!Q5!%)3x34*~(>vuGB$fw(0F@FosfB3n>@pvVW2PiTz*N^1*D>&kN=iqrlku{0*)#0lC=B;<+PIz<@q^B+Kub-1_Y6GM5J|SVz(GeP_T_Nzi|S zk$(e!Oq2>v!#TEc0E&SKX=(8fQ^iJN2;l#AXwj7k>u|^NfQ`k0>6}{DI`z~g$ub}F zC4i}yD^8#T{p_lrtf`d2U)YZfrVexe#E+-y<$Bsqq)%%+KOn-mT!gw?dHqcR{<-dC zWJmv#N3n#4FG#6Nu93Z>$fPl7ir7n_fEI&dy>R~sSV?xz&(H1d?Rt4X{8?=VWn8fJ z8ygy<95eju{|YwRL;_CzsY3HFEq)Z12JNrYNGjBF?U_k#Zzc+*s(W>E@K>iNF9%YL0{_4ZxW4@>kSBluGj_l);OC|jk8wC2Lr}k;uyEjyV0$paTo#OO zsawi#&o!Pt@q}x%hn4%8;cT+xiw0Ui_@DYsMsOH^3ivMXlx2eU5b0ED7uDJPi=BRB z$FSQ2|0dAm4jO@Uk}cfbn`&#F9_gCVJG|VoK;uiWZ-+1V1vD|9uDgKgI{y%Zd|PtM zQUZ?N9mkCHle9|h5-Y_Runz^^yr0%aPi$~;%@srQ@ynm~4Nf^CPZK0F{ca=d0Zpfg z7cf-wK(@ged8QR~cIy1&$zXlA{{yJ3h|pj-c%l~j2~&yY_;X-SvWGAHAV;4rp#bNA zyy#f=US>GpC%^n}mWZ-hSKe7Q9Y32f5v1pSmF^S+luYB}f4v15qqI-3h@M9=V?3MN zOg9Gcc^wqzqtZ7PUVLAtO|2@jvd%$yXM>fSo2HvIGK#jOq^j#l1SEU3GEF3-J)Rms z23Jp7T4v+nK;;XI$x#ApUg+D+`R|SSm)+xIKKuE|nmkF`#9`Ar0r*3sx$B|$Ff{zj zS85B&Mw_OjcU{bz|jRO-;ic6-BGjLBzpA161l=&X%mjUn!u%>@^#%1n&ep~^4{5)3of3v&0!`aVsze9Ly zb$(TfIn$%3FWcV697Ww2lDfPXLvxFYc5-))AKEpaFaSFOp|5N_Scc=H_@L{I+xON- z*Lb_{ZbL^$=kDT&Ic-J@7fiPYN&5d_KGV|wA_Q(%1HcFYs!z6IuBfNLMw7d&?lT{$ zkLa)Vw|VPq&C34Y9WOo9Z?+q(9^L-QSwNCDjOba@c^48YV(VKC^**D4mYxc`+w34U ziB~t<%3^ST`syl5T9bS#=iQ$}_O;wR>L|jU!?Udgk>hD9hWweqtv=z32QP1>LvNQ; zSOSF)@2hk>UCsl7&VBL$#?h0*8Q|Wk;mgPTeIjCfP+p+{AI^}g{XcuVFXyN3po2~l znecIJKTG_xglJ%Ahqqi7+fv_{@7W1HMz}Z)dbjpunBqOBkPo}x>Oqsxy$2W>uoY`A zpV7YeRApF3F`(dcH^Z#@THRP(jrVm+=Ny235PH0J26YB8H~{5rtu8Dkr#>6D>+w1d z!uP3Ls_lG=T6u=;fD#KHdc)^-C?Om()AvwqQR(t#_UTOjpR234^lYtFoSfTykyOs-je2gcQH|A(%FpEi4kloQneo=XH8-D8#vKu=d%dJZSq1l)Ox z_kO0Y5K!^(VkQECO`A;bZ#_hdURhZZx)B@wxSk&);9+m*|2DNl%}D_6J2-uiNhV|a zz`x3%6D!c=cXt!L<}LL2jAUgO2{uCIrIoBy@4R;so4FsUHVCd4C%Rr7pZ^?!b<^)< zM`5;7#Ccl9Zlj|pTTy4$_X*AR-v0Pzwq9c$@Y2bNjS$a$a32f4CtY~f*H=Oa7%n{m zV@#+h=#l2v+f&}Uhj+8T#E=MmP9YL{-KMYBM}+Q`bYUO1S?|P@{f7RN&5^$$FMQ|s z*}D5X$m|19kQZa_qh~G;T>F0|2;@~|2>24DFV5_bYpARBY!9hucjW<~gnmLaZMl%? zs}Wz-mmAjh1eaAQ=={nbTvuxkzBZTNf%Ob5J)jjh_%!^RaEh*s6f09`Na(lE#woI9 z?0tpYMG)*{&mFhhS3-U-zm~3W_5F5i7dbWGzKuJEyB2Y21BSJeUC#^Y-S+o00Y6AcaO8zqrB_x_2GZs% zg+pyAXkFQTF&W(Wn92XbMWFu62^Oq}|d{@y$svnKq@htQ43$H$4T$9Zjn z8qF27#>z^PxlQ8U+|Nkt)9g$~kw;&EG?@QIBu+PaY^9jvOk}}X{hhcumWs$xJ;IKQ ziBa&_pKN-&``*>HL2cmE;5U;5x;3X&3X81aMi}*3s?6`sR3pE9^w~*FMm}z^omgt| z{Fi5OzDW+>buoQ4&Ktwo+CdZU=93XyooC);r}|Ddxlu(FA0%+d9B2Wmd5PK))@Aw9 zwKi8y(9zQR`*K9%S|{KaGZhmFBuEWFsI_}O$^Z6oa=Nmzs+V^WMebdUOgJtPUm$iD z-sn_CKDk(#5IQC5O($u=oXj@ebIb0~fnWK<9}Gg`?VijaI;p3i)#=a!3c9xchO~O& z5p8&w$J>9NLo0Ux$+8^fl1fU0Fy^uUL&~(MD3vCFwG@088-z!Yvbqdzdpduk_z(3| z*3k^ssvs@{il*ovwbLNbl+6MY^4Y%q*$0l)z=<#WUq(lPZuHFU>(NZxzVArV51pt6 zID8TGB-s=e5?M9eDqSg#)A%LX`_>j6AZBy)zrzA1{?)Z8R%XE`tf|zeuY7Lj@8O|m zXTO2jFz*q94b$_dN7FxXG6`-q3B8ffJYxPzkPr}3 zcwOmMwLgb^KIXDfElKF`zU#EU9bI1LWdSeq*YEw??(1GxKvf591( zbSX$OMueES*R?|M<}@jzZF58tMVXn2k=&gHJg~N5WC^1rFtg}pm^s~U|MG>4!F$2y z_^zF|UT-@PBBKHM^0GIUiG9@E0=`xMygqH1?Z~RAsc|*|5oFI>O&dANq9I5~#LjxD zK@D(AKhFL1Oc+|?yptL6!N?D6zw?=ityVk@xik*P@kq;?o13ck7k%4|p)znA2G}zQ z6SH#D&@gc1+fR^GR#uwvRK1?T)eTerWES+f0x)WV$-WSZ6<<{%nJDhtgBn zb3c3_@!sRHmj0_Obd7JNHeIkr3m51L@ul6&u* zHF*5J6TIvf7)ic8otfKM!!-KwU#dZY2L%Y%br z>xIeyrJ(_bKnYV?ulyQ(on*PE^B_y*FL&1{3 z{Vg?hnM>#y@5l$FK0mZ`z3FznhbH7mX@C1&CaAxDa;4Sfc?^zw$yi)_0q~AATaJc9 z_%{?47XFCk_p~&S@7h8{c(-E@2$Qk#pZqGy`smJLK5lUTat?&wde86T>mk`)m4JCh zd)OUDf0L`asx!DL=#Ts643xHBeZ7R7HU#=}+HcRELVjs4p`on2H_8~Yg@ku4-GkOX zNK~7M{p8;Ow;Fkrn#u)iycKJ6#b|-FENRD^{;;qSbn21N%aM+S9dHuhT@v0~ucM}6 zzuxk6-EuX%olM%nyouywzZc-e?{>*i&-~Z(S*b6KDV;qnGfg)KJb*t7!LmaxGVYJ1 z+m#T$U+G9=SV;Xfi5q3)<2G7jR2r1kwb>iYZnxYvLw*^ z|8td$^jF%AGpGa-v;(_qdbzoNx^=%TAWZUGOOsZ|`_rd7nbd)+m#nu>(?Yis2EfTw z#R`ZjsdqfniNvZyFa+<98@!9c#Y5j*$r01lU41(5@Ydg_o}Zba7bF*?0;n3nl}Th* z`Tt+@`ts4(_GhFSR}=WB+rybEgN++Pu8!)XrKWYyWM2r-0>M6A7oLZJc6|MEH1Ta5 z*{(j=8j46X#zymh(axe{Ho^xHtp?i24G65)RFf9pK;RjwzjxE#@~ufABt{SOc@w}B zeHt7b4{QW*sm(1S!+yQ%LKR=IM5(4HCCM5P%p7P-OfIoRU0sIHF6bHkF|iD7PPj6D zk@}N*B6{Zd@Q{{ew0XCx?T!d3%w0#{TCi%YW1w7M)2z9*`7a7C2RHXObH~>pNJvpI zo#||Lsu;FGr==Af-6dwq(qMt(DIl7dXS)GcJ>W+eP8rC?zP8^g#B)IQWJA0}M#Msu zisVN*UkoRL?Ts)Q$4AiQ>r~c~l0|C%lf!giT<>p_+Zw2{{g0!b&y{tFM?Eba0|T?S zw-*+T2;GGD3UL|-wA7wb_}o41t4+~v>)TvxTtXg;08vZVPF zht?zUudQtWFF?fJ`pwBs%y{OfM6xEK8&_pIviITmnA}nyU8jEwpeVyb2g04{_spS~ zw}KdSA!`g4{Pg_6T;9O99I+@yGUbw6i1!iV`7g#A^n!43+vK+XmzS5{zQMzf{$v$U z<=RCE%L^z{W55XmTu}?A{U1K^@}^GgyK>0xm8h2fkDDGioHh-tX9YbYla(5_dE7iw z>PL?==R!^{Bb95ho?ksQ9Sq4pn>%h*|HrAlIxA8SFArHf_EsI21QUCkKS|m1#RX0; zgETciTZYT9gI#T4$Tv@Sml`7cQ0r%C;Nw=J3XpKyfxF+wVz4OrIIfr%?|;*xFQb4_ zb@d#|}{NU?TugyYl4Vjo@Gdzs+BWRXhY_oq8xGVQLnUefyEafKOC|%I* z@-R$a)!uG%IQ<;xLpQe77e?xNpMy=WuDZZ+eb9-43DwFv!9tEW?e_0-?2MraerkQU z-H#qwKO;;NMF^}FyFVEnqn`KgJ}EU2yNBy2%;FcCdPKF zgYn$qAc#X9L%KrCz4$Ro!k34NiJZ0s+!R&W2^v*~tAft+9d{mYkBm~tzZA0LGMSm} zCnP3=xS=~T>d{QuzQ16H?J-Jg8`75t;Id93BqUUuD(L%3%}kL(2G9QS@zse*jINyY zdaJd&l;si_>xDC(VtW#R;JOV8w7-8-gEQ^f*?BK-uaHm|qE8lv8bR?~->*l@Wol|b zoV~TJrSrDeSv6m}Kw4Hh^#14KJ$Vo;gF*AV4>9TdH|K?+5Y!+UNsO)Ouy6u^xipGK zExL|JCoMmNgP#L?!4rG;JAgY1^LHehJc>=)(b19EfC80nhq|WfpPkdSJe_2zoq-me z>z+0j$~|M7)hNQ2-9&6diO`Pjn!B^9HSNa4xw$7)lFrN|bwy3+F&i1&QM7DV6wc*~tD+bFRrW}?Sr0JDnH)O7#;-eV$*v(Eg^&Wtbc z%I)anLB@e%Z8R*6uHE^-Vn(XT2I}r%sBXWKk{TEE9%CZ{U8rDj{AUfw5|F`dNLgBv zlusWW9IdRVI$!EIo_=s<6iDr^fM0;Abaj*puZwV26c)Z?n%a>7V)D!NZ+kE2a}BQ; zxNjkn=eh2O-fa#1l=u*bm6(9x&BCk1z|>$b2HmoIBIM#R*d4%JrOgQrDLjO_+2QSf zyo8~~-Hw<{@DH%75ud=R}&ca4ix(&tfFzKt15e?(*9nke5!h)}42~xk{ zk6G!dsTnx_97>YF#_#?5xtIkQq$SmuO|7~i>%{Q{9MrM zU>pT_Kb)kUPqVR=I4IS0KJwZ9`SxB6whOG?3BW9FrWe2TLQ@a?$u0v@D9W^O`JG=|dwD1HHoY=J|Dn82mnrBIy8g;(fbakOKwB?=i6kdKlhpR~%n20c`XyGW!#Rd{)7xjbvM!SR%Ha+1c=CxqpsJ zKq~qL&b}SA;FaY0T{JXB!cyivNh`)k{E~60eR$e<-L&}fSuIgF#~<@)Y@)04WjMm6 z1QrtSgUSrMxad}OPf#ue1=u!zvRhYIR+rFpVC2~y*jTXQI691{LOoeH_wrf>#{Z>G z3HV+2A9^P-lJI8>C7z6&1Jd$yd~x(lj7uHo-Rpgl9Uc7NrR)8y$Mu@?)k{cqTLLq$ zYE+8emg@1x-@p{pKHji&L2tjATVe-oWp*~whK(T95uJ#@vAWH_aP66&j_buoS52|D zkB?8Q!PNAiZ1?l6W81JI`agi100x|V{g0tAzt1z+mh-jE!K6vt#{yVv9x+vZq(l~R%ibh2yVjfxpCPOIS@-vm3pRT_Ierb9j2(P9n9^@VlZ3`c7^G>L&75XxegSeWE_7Z)4<=-3?g zALIP-rjN5DbxwARG(&sDZBAGAfkqBP?Jbd3iftQyz8gLdFm$>G%mR+Lqfb>Gmy0iV z%B(G(T;b!gcXMb$VnCwr@V(;*&L|ox;>kRv7YI}bq=qzP6W$Tcav=w$a%#gIwqK8` zWLGilgg^$w2e$;GHsl(-YQkZ_pUATp%7$LLBO`kKdG-D_j=6IVyoeFx6&CK5gpopL zAmDCyEWAl-9d#32oicY3tRV%wLU1n9avyYJj|gx1kG8o#0Ln+YwyUij?X}ZvD=(7K zoj1cW;HkMct*Twvl?+-0$xXZ3S;-W@jAPY%zyXCgg%)mx0g)C`sk?h@t~v0!1Hi!KeOP2h$1#` z{(ol$c2g5j#RFYjuLC=mnV`+Anl!L8mBWvLk{L`94X8+5gnH76sP@Lu-i9bbL(R`r zm-&d{*fEPL;z7a8EI<)~C^iBwvP_Et++aJUK`@FmVxI81+$VBvq;@!5{O~^WJ)cpS z&}&~DMFy9`QTRL$71Ae%rt6*FM=9GA!ikl=@7!-EB4>;C}LaoyuDMD2fU?J z(h%4eXL@budJ3Y|c84Q|4yt(KgiAED2%^RP$r=Ze#=@?iASt{o3yYSY^}!wplFG>s z_M8_fP)4}@9bC4s?s4%j9_8uh%zB*{gaP)GgG)c!rVCtilhcqs$DW9iB68s2RMj z$jBBf%>f?@%EfrNE{l^+mRkW{X?x_ejg6m?13NbdrLL$5x!ckVV#(p=q~5_tE{Gxc zRx~R&^)AzkJx(pU)YKimmcMdg5<)Num}v+ZVpEfhWRU}-QeRP1ksq%u4n8Amz}3T9 zMuNCaz#Q%{Lh2aa;n5MV-6&12iX5SZ$`~)-Av69UOYC(5t{e@?Hzr^tZ>3%DxESFp z;fv@Un~PhAVG-F^z`6#&A$s+%t3l_L`Z$>>TCO6NmdQcMc^byq)u%?*`YGYn_X(8r zL>_plo7m|Zf^O3K*lozOwOBr4trdLu@+3Sno_aaQyZ77;RHY|TdAg-t(5^k(pg1uMhi zl0*MjZB&CZ_NeXz(Mo$R9A2@PTyd;YaoC2-cWgQdH!!M zf`;OVvNbVKw9L%7vyf%TUd<`L(z&VT>pNHh(|;+|=at zs)<9}wZgEImkyOeF}3auS1VIf%?8TrSDhG(jiu3@nxj^+GfBL-eoBbYujAlXP`C8tp=xYx#3`RSep`8* zr9gYn2XV|qgoUSs5Lk~#5GBE+AxsbJ2eV^^}%nya~iV)Ha1B@Q1K7eHgJ#{0ka zUgsI|@QX6M$Hu}EjbF_XlkDb3gKZt>C0zzrPtOPA=O0cNARU0KssHr%mql8tSL%|- zeX(0@7Lj7MqW2mHS6Pqi)zQrJQszF zo8uDI6gcDq@bl;9ICW8tR{4wqOc97ZP~SthH2jNmhoXxhEq}%6*MuX@idpyZ{k0u} zw$bJ^eP5GLSyDQ!VFbcb$^o|Cx38~vU4}i)_P?h0Gktt6+Ig4-U-Aniz1`hAo?;_p z?afPc&4^mep{+m+N*a391v zziXVOCc#hR$V~nv70YIHKi;yqqO{j$Cas72x%s)J@yQV~_?}IbG3|Pt?L*7@Ux$?G z7wHQJ{b_n0___<-w5=)jb}iWWh3fY8eHAqWmY${|IjWC8!TR>cDfISLVgir2&5ki? zLKKkU-*lM)qeih!2ufc>$t>D$8F0RXo+!}}%t}hoB>BOu*75k(dg#>lNIoCsLp)u! zS{w&qqBLXtkQNomWYLrZnWQ+%9BK^to&TqoX<^5Thd-$ zUR=yg!~NwW#%J+h`=wTt*AEmF;vZny1p)EE|GoR$U$dVd&*OAiNMvfNsdiMW8~0?T z>#ghU)7?n{Bbr4KxcnPGHEI+0m0^x7#5AR0cc}*RmF!&=YPMv|S#=(TWK8Q3e^?C< zlfL9z2UFc_%_42D!E67$%)Oq9+5Cuvhkx_0y#i^uq0Swum|<7TJ;>C{vF$x=mH z6iS=>p(u>;_BNxr{YI_tf~g)99kCS)p^r3ayEn{kqsx9LR8na-Qz+`R&O|YUhfAqz zgF}UmMqkdwy%*+p<(%3Ppmo^AN8`@!(FBgqsv(5t@$jy}k^`54fmPm@wHYwDKb?|0 zcYrzCm0B1hE+pmgbJ_rU`Wo4xrFQIo%2n3mcSs1VjoCWt`3E z)8nWh%*|UWnC-`+Kdy#H;_cdbwycj>C}s=oDi#$Mx}B3>#Y5$uJsAMhx81Mv#t#hj z{ZR7fE*WuS{IxVZoGASjii1{@b6_3@FWyNO7(_1nAZ|Jo)ef|S3l8)?G*HOUmz4>< zJg!h@0*3<|uZkN~Bz$&~%K>3O3n2y=6Kk=Z=KZULeKR6`)9gJxe+|B;c)U4IOHGnk zn*$R^530ct14?ic#M}t%WiaR8U;fbiOd8vNpsf%wx=A@kmClw%{W&I6RY2&iuo`&P zQpmy{f{oq*3?mvxd}3K){WFm znk3&x7db;-t|v{swI{dX%vw#HdxU+BEN3wJn$a^}xW&C$RyALI_#3l$+hBf)LzK^z zU{Rq=Y!lP4-mbv>sO$;7groi!fh*Rmc+^ZPa5rK<04QVyxO06)RSyA=zvF|F4}D+z zLDzmy`jWVL`{P55>zL(12Crqi0bGSG`-2zV+7GDR{Pg6}49+#~4nla?;dm5P=Nb08 z;=$I|N3gwRH#^%a%%T$H)T2)$e}j^Y7BT!23!>5AD*M(!53n1hU`~52M#5I&Vl!yj ze((akyqfJgAqerjgOzn#F;q&A7ZGkh4Br-H`1{4K%N;BTJ^O&lTJ=(I(Y5yQO&8Zo zlhH`yx`21=@4sxw_3WtVwvZA!x>_NE@nAHIveE<;|n! zM_cA@{YEDK?b^y+<0EkZ*C3fN%f*^cF7EA>Y7h&wy71(e`I+vn-hv4YO1QwO}1g8Il;sNubaLJ zVnDbi(fj$7TJ;yOxdP2{L8Ye+f4+WEh*Dp!cGXc+n;V%BL;<$~?}Hzbe^BbH_!cU( z6OA*La%y^DrKwR=bvf<35kn$)0`4LCOiAha@L>I>1x#F0Mg}1q0~V+khx^0u@a@#3 zUVqcF{FoS+9zd!`gGpzWl1;f%@wv^?S;S~+Rtl3UnpjLh$Zd461Fn9CryHMy)#jT- z0c@M}Lh;~znMU~GH->DDrb;Q=1@da=^Vcg}W--ewpL>YhQ(tNiutDv5J;b$OsVpvk z2Bopcv~DH-W-w^F0pVSws;a8*;n3ij+4zP=Kdyu|L;W#Y47G?S39-y)I5j&4k|R zX3t|>09S|XZijSLJ`p0`ULaLTKb)u6u6+}KcF+D>QYpDT3Ae$KT9#& zJ$oVC7Kh*eL)BYHRTZt@-%3fxp${#Hw6wHzH%NE4Ae~!6kT`@$3)0;n-7Vc58brEV zK=8M@*ZUpsJBEKcV6*p%XFY4q&pcqTmntrsNveycS3PX{4j00}_U>Ty{+V^B4cv8) z1O=T%3~I0Xcut>~QUoKsfD`~NY2G04Aaiid`!!i8IuFx^Ma+|(#TDUZGQr}@-3i)` zu~+S?`w!OJEB+?z2Nma^(fczLxwQ5d5H2!8*83LG-8c7(oM{d&DWl*YC+Oe;-HW!( z_Bl0^Lzd_t4ba{pQpOBzoI5P8thZ(b%_=28jVP`tv41T!dsZYT!?S_xY>*SSyH&T& zK&Xe`6cz?vQPzZ&Eu=zUdwRynBEdey(}UNXJB2Rf-{WqZ1b%nP)0x5axI9(Vfbus^v_V9@=e#s=gD-Z? z{!dr%lqt#NAjpRF*+!MYhQ!fSjf|WvZT*i<6-gEka1)Pkz~f%Do*gzy`PGqDN(vC*HpJYO)H zaR>ZaJRTXH$qcwRP!#s-_&MH*$oMP)q*?I$?y7gql$^L23cWi*4PNe(b+ZVOWkAY8 zR%dI^Walj9A`jReS8nvak?QSr{s1B;C)I>zsIBCLUYbRyom)y#b-;So@iZ~@m7w~j)2E~fl<-a4}wYhQ`1ECt~Qyr?j z7NUQXR(<;UT&R|4w9UYaCUPTH&Dy=0Iapaul9^dL-Dmb?)PM%VnEkEtC-U+`K*uc7 zb0RkUC4Op%C~i=7D>)e$(6%}?W&Wduf80uCWZa8`UW<-EEm(^6^myFVq+LWj3-`-R z2I8~$iUPxM7%T$ynF|T!f{DRB=(v&wvYgLBgo)LkH7_X!LyLiJ{hJxlin`z`mJzZ7jay>hf;| zZ8O;GnZ4rxZav8?Cba9|m?Z7~HEQNemp0RRT@t-6A+1X(jcv^QjTgLn)_ACQE1&5TA zkBNy%ka@8hzoEXqUN=STRDE-IrPWtlTpXlqe;J>dvHS%L;6bV+>4*w)b30zgoHp!O z)_skHSBHQn0&H-pFe9@Wun8wlN-DU!__0vId%2Js@QF<_a{90OD-9pBb0icsxtM8I z{hl55y*6a53L6bId6w=FInjj&fyK+ituI8maUO~RiCTNlLLrYbXj{z1{Fg%@Z0tg+ zuhL0c6<{)nnoB$8Zc4QE*SQ{==ueik` z@(J@T=*&a?4>x|kXCfrb{WjpznkY!soF0{*J5SaoJ6a9b&H+sv#v^8jIn?{^T6qXOIU=JX`K5eq-Cm@h9M7sG zeK)+Q=kia!=Ykn+x7ylzTQ{(Y-uC1_DWPFw8$g=U!%|!g@97G_U9YhKc@1J1*S8H{!CdYM{CIb1%y9J?KXzI1zF2 zaA9L)q%9j_^^02(2fH5s*n*DuyoAwx-JV`&J41nKXogig-8<`EFjVOA zX?|U0!vf$;LnT=V+v~g0N>w2J0L#nQ%d2T-ZV2>r%aY|$$VXm@xTx+c4*n-Plx3VT zkElp-Cn^x?vzGY(LpTEU3%Ve@vYyziYG8h*k-h!*?5Hpty&H8=uSB=`n;g-9)rryT zM0Tw9&9nWk$@MhQ?)EYryZ;Z|QI#`KyhxLg5Is*C_RSngOmWSbAK~e@v5oFmEQ6a7 zUrP-po48wO%06+EQOLi`DtA37U;IzjVUG%c3;wz9F&e*XHS3GJxR8DuBSu3OuTrIh zfNign--m63fz+SCK=wz%+*L=@f&gmmPfUEJp7mCtg-=D5|A&42$)~5gpi6c={;9Ll zF>_i?Ut0xy@lMPKGqwxYPRvY#VMDV20HRJb!?&woqh}yow2^E5+#3?! zv!Y$tt-%X0|4(QxYu~+uS8I7&OR^|sXkd^DMi79FF^5ds;Td*YI}F`ulGSZTtuJ7g z1>|pnU)u+M{nl)PIXo){Un1&iu6vn+^%2hLM>*EL9L(NoM&_UMz7+F-zSK5&;U9PO zQ@2gys5mTto&Bxjj=46Nq;k`8^{_1SxWZ6=+uk^oiZnS{o%y2e6hFylSF$yJ^ak_a zglRP`CQHWrWO9?WPp||`%*-S_&bfK7hUPH0g8}*%_lJXg+md%Ask2AOU<>>4*%#Pg z0lC9Hly}nhL!$L;YHV!Ej{|X3`Z=CEEIPZ3cO_+(U18iN-e8fK_8Fh~)((bOquCCdx$$70SGu%$EgPKE>)Y-yy0>fU!Dt+k9oyEmr(z4(sHV7~Fd9Y6 zoQeF25#0)~dn#jf1*@(`e;ivfKiAi1D?DE{iubKMe?OMNPV#x_TFd;sPY64Va~E)N ztC_Q1{wNO3K6Wo$j}86a$6ZUpL_7GSkY?pXD)TIBEYM^pv{=QK^byn8cb23C-FRip zKoI8B%%$Q?L=}@*g)jIy_n0~hZ4{b+Af7mQRDH{z9qkX z{&pkt9XvKTR6Suq7(8Aa|4MNM7{EE*XnuO(Of_C#p-P>-5}|i#Iqphc^Pvqbh91|w zdR!9^lJiVqgUKu#m8W*J@D>yH(e$8T0ZDuEik=s(t5*`RN#bZm_-(8>J=MK@U)zGzN$%VSgKT`&n76rsn{yWJBSU<>Z z%%@3o(-W1Sl+uIvCWrpDCy(!N8;_iT9b|hk4L^LBOTCYvCp!Mai#+n4R;yWg|w-6ybT^*Y82Z@FuY-6P@Uv2jtTfNdG7H^jR zH~!7Ux!z1(IUT`MUXm;}?%pc|g29WPdnB@tvxZJw5c!pU?KpRI*0J!AXKBD)pIc~m zJ1T1x|G(ewi0$i-qN6az*Q>Q^)avKoBJ~_hIRGc;hu4%X*FM)fqX9p~5-W7C&=y$! zho+6Ny5gx`*Om?gQaq=mZ}}$Pe8O37=45*QnCrYmDk<}^+jp#CfB|<$_=NRdPM^*S zUa6*6j%erF@ozJ=&lG%R^Qrca)BHyC(p5&(4?z^$0@a z-7go|Lyakq@AYw6nKiv&HrI8r)l^RPRx@t%GIg_PsQFJ&L#@n`K{`lIV=)ByRu}vr zQOQbFDy1#%i?A8A|BeM&S6lveXV|6#hg$V+D7l_%ZKsgU=fje*vc377w0Ox!Wbpzq zXdEyS_HAj|Bek12(aKHa?wMwir4p6xhu3#ON4x@GiM5?f}<@QeveqZ$7G}_CTVCai5E)*hU zyF^lnmCDGSxFQ8_G12{%QX@gAK)j`%eT;jICdEK?djDiZpO zv3+jVR*U5Zb#%EJ1!aOBmYcr@EgqpM@5!D+&?E$qS-%QegNVfoFAXfKlJ-KWl9Xji zITjXJF6`0dmmT<6nb-!i3qZerbRx$vKj+Pfau~j3;P@oD{5-HvW8|?98Rqt9GX{7Q zsUQ!kM3@NVAAfurHSP%_n5p~Q=Z)L3aHrV91Gd%janUv9@+zml>%Nv4GZ*ec(fL8Z zcK!3OlR?~*(TyoZ!q-p#lg=xqW4@@^-!R*yUQk7muSuG9DVU9bh357h*G{cs^MWIU zvI4_-=SP%oYCcEl3@a(JX}2D*aKzT%;{VE;8>TqAo*7!ayq#n%V>G)2NVT2HHwQne~#Ug^gBmrC7J|c z>MSYYe-|d)eu>xPRY$1BG`tj$%CD`lEB0S9LSb>S@;E$9zXd&jv*~s7mrP8|3PVhL zcKe&?$d7N13-u$IFc)5*6jl(n9m0_VhD1sm5A_Mz?B=Cd0%}UM@9Zac?v_-G^^es; zm(YmB4?VH}vt@n~=I#`ig_ELe2_n;g6MoAA**xhkq7G;A=z+guJcUq(P>}!Ev38EY;rO_&+x*S(K)p(DTXNs3ct}^ zsL(fI0RJp8+*$5oSH0{xh8$`-USAj|y7)cts^3o^kMeKy|AYnRK4!8iZ|xN)aq}+( zzyoSULB7@I5(8+BDJ?ApHNQ#nGj=?W@5r>l00fA7xOjMYdR||hVeY~i1(*;J`jChw zM@uyrMuY|!kYUTB{FwJr;f<9RJFnz*FuD@=Uk%bfJ=glT4i1|D=D({Irxe9xKNVfZ z$l>zZ(Q&i!TG37K=y$W$JcrhN#qzBk|4QFDfeq_+&TRttVHx;+QoNaE5HWIP{OONa zZ_V*$ai#TB`rP6=MP;<@vGhvB8u3ulC@=fChF}vyvig=`-m%Fgsy?R_w5an2(fMdy zAh-?bUts|-^fBRKVZ9~fAe)w14TXQLiZv%;lTFU=m}HOUoz@u%xl&*;v)O>aKF8@z zbGr>@$LQtSy_43JeQUtd-BYXFt-i~nhb7+oiQ0-=NvLwTfP)dPZZ}?yNuKkHl(~26 zShxEtnyCP9D4)hTH4QB7ZUOt>VW$?AoVg9KVAS^;z27nYG*W zj6uS(lf#p1idu4{j&sXS0Ut6M`_l1P@|_j8e4DgIR@R@bpOq;QI&P!sP|8? zrJt>9ss+w3_}?EkGz-liRi{F?KLC%_3oZ)O2gX_P`jA4*WGahrD7<0*|UMAOb>W#V(F%I@RKOTayt@X5PfY|Zw-+pC= z9dgp_LvUU|0QaBEV zn^0?K8IwSq72m;kOVSkw%$S-Kg6ZTJHlfhkuU?g=%w&^s?AVgi@BkRQm8GBy^Y|}{ z?*InPv;XdMfa83 zO_vCQADq=TFv0=R`^r&@L5+&26pPC{5{z~H%xuQQMgbjyQ8t6bGhlOV4(|!zFEHhN z(A|G!=Y^4))XC{L^ynS`gYXiXXmU$r@_fq^D>4q=pXB zvWs1@lZI*plkcZXDGgdp3E32{LLaKU^-?E|zl>lMPjHWT53T<=qps7*~4G|-UOflujnGGqWK1s!m^9VOj6DJ|0 zuBs*mKcyg}Y%-5&u*yV!kyvzsaWgv?&ihjMoB0ks+j_{qNycuT6kxw)ba2Qc{~Gch z%7Md6g2q297!A!!w=F8Q69>ejTxae1tTu3Lf5whm(wOc@$|rxJsd|A;h9aDrjyL2u zKXuE)j%1sbEP`PbqfQ&@gFR&~CmcO1@Cky!XalA0acdKsOoq~3c+;~?r8z7%c#jvd zj|?gbidyN~IH237dn#~UgQWk*eO?yBOzACeJ}={q)y&~bQ%;B;uqCHq16|Ckyey2w}Ey2w{ZL^3)S$c zt9ML{i#2+JtpN~g=u3+$o7bo&+lh;N(wM{1TKD>-i6HUP{;(okOQNv8o}ODhX&qyN z(%00U=t{BAd3iY^M7s(R(exQ@WNmg5$~opt+<0v2Si{_{)uN@+pvyuwi3Pgrwqe5z z7-%Eddjnic@BcMnY=v18$g)xv9132R!a~00y94B#Iuwes3~Dk{A&DQWxCtT5)v-Dk zv@eM0#kgE#Ki&Ky?Gcz&tOjedK9j$>J2104DQeT%z@B0PZVpU{nF5wxGS=bSV5?Ki zHj7WEBxJ8~AVXy&38rLz#Owd+P)QvqqAq<+NA7i;p|p6SRP~VZ0LP)AJc;4J4)CT0 zx>!BV;*gISuH*uDUJ7;X@p1!~q<&Ms>>ZpOEHV*7`=%as^u1M~*{1N%RwZJIBsJfK zhmnD&J)@+%`i6tCz~yPl!A4v>2|%$OG~E*^woOi2 zSyF1@(UoOMpfP=XwW&a#v}Ec#@GNiIp>EACV&HIiPE^9@01iZR61tH2$S6l18zZ{e zHj9gXCp*eolvZ{t4o9m0_Nl0``|7HqyQl5Hu*bZw+|vuqpx{W4A7^=n9T}aI%b{eG z_@OwS!-MJteZKexp3CrUWU8DPSA6Dr!!UMlu_U)rUZ)E1Ca5x`%KKAgkGy=elCBLN zuZy*%EeT!zvSj$Z-MHkgu3B(U->L3gZLO5+xyjUR9olE7sa-jVwmPMke=HgO?;zpE7fmKTVCunYZbrxLx^K2L zIVo!+K?+GzmO+cBr>nK{;(E;$@%`&WM7XR7(n+72 zY!oC}?!RT`e*bSQnGs%zkCtQcYx%$?A>5pw#}&KO&S2eqh9L=^As)8k+4Wl-n2dB< ziXVHX^=uBF$#aoF6f9*+;b`GX;Wn&!o4T|E={@E5&z^U8f#PJ;E&$4VwxO=*pyYT6 zkE7Mu5}gYSv4YSk_K8j=o5qeW6kK>$YXuXPaFbhUWU(2ijY^Tq4^@3n)rL#T0co870 zq;d`V4xL@?xh>zi&y4)(0s-K;z3leYKL!RV`Q5-c#7J3J3fa;$q;Pj|U7pFzN)H#~ zNG%le0n^a~)$+sh4;oG+mvZ+!oz zgXTj#8Frvo7?cyW-=8R{iauf4A|_fnKiTN}PD2q4JZveDr5*D-@01(^OYMhm%-*oN zwXy+yJ>+0f8H zHqvEp>1Hn#G;#Og3EDn%_Vv6Z7xExSP&PZo#}O8FLLOaMVV62Y6xJ_|YG-g|(3>=VYP& zPh!Y7kj*b$Yx_hEwn#HLe{&>>h*(K(ih=!5c`p*N- zLSR+7vvXD*EZO6k^Igcfat10Y3Js|Sh8p+;Yi}IJMXjx^nT*P` zzzeV}d$(V)PZ6QZ+IBux4rXRL+qhYxHjNnl=r{vql?^&s>L@5kIyFcSt-I|_O+Gpy zw0jd}7&OBIv2a@C^|o8jAuT%>V8YPT+t(Y472Fd6)nrNluagIc8?&^ELa{)`t@bOs znVX>`(*$MQ)OR)>>`_w7wf-_OvgHAkB<7thegj_A z6R3DzA8P@7o11G^4@&y=oxWF~68t4Dx- zbCnAj?>qnf8RAyGNu7!Ja|+Mn=<0a-!ZM*S~X+To}kT^r>ud17M_RWp3m= zZ+*z8TDkd>Y3g`rCICR)_W5|JSBR02c>KqZAF?(@m}sr088*XYnxt@=6U|3;m)k$R zr#aHMUcSaH5Z9l>jkfXhswG}R5B@_*)k||9+WgmI_z-xXea?s@oh`yY6d(V_<4D3L z@CQs&R-CKujxBq8PHj3F*7N~{NSI$QBR1Y&jrp`Xj^{%|RzY;P<>n^%s8M71<5R!S6k+!z` z)1-^iQV{6^8n-SEW-oX9Df4^AV(cII0`_V(>G$V=f@=&FVH%>yw!fS8%vDwG0oOdG z_coW>QoT|oZ~XpVdcJ9JzCrK#(`t(UN6e&~%3erlNxSu|y+n?&67^j+7~>qWFo!?Z ziuzt5PphAvP7(V0D$DmfkAjzb!1Z~mdBG?q<+uuc(%kH<|DVycI%Q=?M@gOS!23I> z`6O2j(DwW44&=9Bi-=sa7=y@PT(bUNR5%&ppFhuOyM9)@P)aHEcaM5)@;;|=oD0E+x5GKGYQfL1G&^aHbYo`h50 z%4?JKuU|7mpMESYDhlLeAOHpppJ7{UFX2W=f-(!}Qae>b)Ts|C=zk`TkM&xuelhD& z@7>Sl*6KRY?5+NTrdvroQ1H8y&Dt668mfcMhlyGP=eY+2f+4{99 zZlc;A(CX{<4&GsA?3}EshYDI?eG^%D`@RWyvfW8jPR~E@?hd;+mLa6R>w|%^>)@uV zt24?sc1V`6kPQSu3o$jkX{#_CN^+7ll~-B4^HUu!Q1XNU_di~HDZF%3Wn4l*eMEFM zlUg&HU^GH;OUu$sujTJdvChswT*d@>oNs(@RW%QO(gijdBn`;g;s1Wpd5^gIWv!!Q zf;Ke#qqyE{HT_@|*i$~gm)6!z&=zuHoJ&Pi_ag!!`#l0C$R7XV^--`qwqIk9_yich zQIw%N{xvQUNI~vJ)c*;Ob8h;IP0k;{`hrwzb7gqd^EZh@^Ilb0xI4JZbloD)*d24+ z6|2@$m2rkTgP@nJ>W25bKfyQhx7qgmqk>+q`@!_?-?~E*PUnsdoE55XUYGUm3!@I8 zjN*^)24F4|<9D|HY|NP(D`|5Yl}DFzy`JBTV&*w`^gUCwFDmv_cV*}^hD=?$h+Cnkwwpp znaj5yt}bwjiw3XcTJNf3%d$by~m#arjw0Nf)IFesBH}8 zuowp+M_#(Pg6rouCRwdL^W+6*3tZ{P*mF?UI?T;G2t7jT*E+tU7t4_Y9Hw#HiR zZ)-KSUoEjz>OFxY(`9g)E`4rvMUPN0bI1VtTElRBeZ5!O9l~oQvo(tyazh;A)u-rT z6!#s=L(EJ}qsHM?Rj*l<6cOt3s(*EbWt`YK}kbJYJXOKA3Upimox>W%%L1z)*95dr4JtvZm9 z1S8@6ozaU?Qc@zehueL*w_0$0Ka|wm#789*5Co6ebR4e@{#b#kY5A*ZLjcTgAb1Zt zGx23&g!~`;)a1~r-nT|+zMcCjLObjof z5K(J!6j_?3WmRESsno8gHS~v0|0FPXc9G&71>VFfLAyfjvUG*~<2XW@5;kR)8U+1Y z$4~k>j>2znY_T+l z8>e!hj76^fETMr6hfuXyBiHH{SXqd2+I$YUnvfCAR8D92m@$tLy4#VsxP~-3js%0( z;Q}EbI1E&fea)F7OfuVm#D7M?+DY=dgw|3DUw*nW3Y_(^=?d(q>_ohFnuv_x+Ge9O zQlP&=4CR4SAOW{mN61>fhEG2WdSC_^STBOdDS5@}RMrR!GV~`IH z+HxUZCYi^C5m6>VWZLpyczluV?!EgxIhf`PbqNQ;&#lISWa>d0`HIiORn+I8u8zRf z5PE6x9qSLLl6G-<1%$pSqxY5c{ygvtRo2Z0`=v*h7nPnM z(vU0sVNC~q&*yIpXwAtdq^w|hqnBbeqR zmSC3U%-~Mh^c86hk_=iMk={JD_Q0rd+piLep=D~iQkUzW$TuH9_N%13xVlFJ5jNAT z@*AX(<;~H-AEbGQLDRB>EnPvBhD{yJc+jB^Y&kh>k#A&${&%*Ze^FjhnX^d>Z6ctK zJAlG3E3dS;X>dH@M~f^d*9HZ(LBXIGeCN|iS9UgKQ=3$jVjBC=aBt*(y%TI_$y;?* z#4NmLt5Z+VFaCE>S66Iwpb0?n@nj$?c+Zkk2h7Q)CqgSUB0G_S`|iZ9oG9~U`mQ%* zb-d5ZZfnD43FDm@PPHo%wY7YFy|#!6 zFG0hVh{{3@hYNxq4|UbIcVW%MtaMG<@|D1cQBMg8T2e#u?qd*T4rCoE9imEl6+}~j zfoGJ!+QYBu>0h-M{8Ra<@-m@B99gR1nvbIphqvCFw~33EL}FBtXSt~i`SA!W&GNM7 zc^6fqZKspbq|Zj{LMi@?I({e}3#YQACSDUAhca%65F+tw?dg4yC|LY{`wPPQQbF>X zX_c?G7NYtHJ1+naF3m=$d}WR!P5iRJ!SE?LLQFxs#BYQ)aWL4ekOxX>Z2Y8c4!}^{ujh(;D$>cU}Toi*#e>|U{IWg$s2zh{RH`u4eqaw z3G%kIph(|QC~P`%2Fk>f%Wa@5qEc3Ed96{xqgf|JId+ACk*WYglrnIG!-@OH@4obJ zibBos&8^d%Ce)PpkV@vEqpaH6UiLrGBO_xn1h7&g4rmmeAToyp8zsBdupHsf1OThemkSXbI4fsmI}E#Kfc8~!$jxGZDqj<8nw zvG>e1l?irK{nGU0`JEv>luCtJBT{0hRf@B??6RW8%DrIEmxD@JmWsAVntXCY&U}V` zql&g3yR21?0e7WFQ_wHe%BxQ5!#88E$A}ETHYX?+Y9tRuO!cR+{4hH70ZUWwb_+X}F@utXkj!=0YYTHDO7;jNm9daFc2yd&ti*ic zS%q4d!k#yS(f-dsYKM}8=^>mSx?mev?SlDE`{hgP-#PNVJYT-`JMyuWxs0&` zlQ9YnxbOUeSg2kX5^w(63PKApZ{?z6&DdK}06Nf!+ish3)?6B)j{N*kM;?i9!~Ii{ z3zZGE2NxeBP3qp-+oPs3RO*wQuP5(UfjZ+(<6t4KHY5_vaHw?i^SRiPg6953Kpd7izUB?jX(Xc+tOxKVQ06uXA);) zKelx1(|UOTS};I^*i?byt?NBXk>b@@s&NO=_4>9kQBrd4r`L_X%+rE->i2DXd;5iU zh4JwXvnFjNWo3DoEF=AM{o7zd*a<=cUpRP`d$(vSnmMTTDqdacMN^Hn3JO4{Ja>En z`o6P>@XP6yJVK}~gu&LqkLR6*3pzMq6r(L^V&U)^zO^s=}yo6DS_y@a%KT z5_JZ_5tG%NX04hgvpIfr+|Py!HyqCvM@C5b!YI zw!Q)xZ7S)_U~Y+x*~_SFQ;cPldJH9A2$Y97?4u`X^hjq&V)GUpAZH$^a^{jyWDNnk z0ZtXv_fXoe|8 zAgxZZ#osTyT1u7c!%U+6 zOg4rDZ(m>WK=%Z*&<#7jyo<_tO1HTA#@Ap&LU?%@4S$$ylG0T*7b_hVv#ho0xI#op zR|?tBZoRw#LX6{^!t2r!JJwE4Y;2?^rkF<&CU&6KY<>jP|0qOJijZrqwwV`~avJD( zc;9&NYaXLuG(D_qoR|ChMK>k0WpgjstZgnTEsju<&3$0-)ds^e!L+&-a{OlBznx&n zfSuLHa>MK_yv^osK&#iy;OjT{sR{8;Gq*+m>lH}UDs3PX&SD@1fi5jIC$6ZD8qkFP z5y)WmZF1lJ>`zPVwg7_i*o?vt^O!~3S6YV#huZIVlfdqsaWsrekF56Y>>>iD@`2c; z#c3QHo?duAJ3XD4pe;(xCAhfIw&uOFw$Xa<5Ju_sht%`BZ@Z79dwE%ykKN=ru)8v8 zM9?F!C!)@E;cJO@g*zsCZs=bu3O;Hzz!Tc}6P*BrLV6!XyVCgZW z!4%*3!l2XuC}sE30aRq@A<=GRZV5?IvZG!3=NW5V15G0JwT(0Ju4HV zoV;9K6S7Z3b2l^EQ3S|;1v6g6LQOZvk(}2D6H*uSbS&@Ek>{}JRC_2__#b)_`<8OWK70dqBRnp5=_s$fvlf5$?&BQV!nuS=?`xF_^Bfh5zgBi*Nf zYEDB+1+e`=vM~|(jDTf%DyQA*PKXjQTuS~)u_duot0YRP91^aRA3Hbm`l2bU?|b%7 z2%k}&xclCL_rRdu)3rOa&jUx~I9aZHQ3Pn1Lpd!LOpAWuNcw0b{SJH3f-?)3Eka9#OauLQ%j6k)jq8!~Ikf_L z0)I43ZCVIWGH2v-&&u=%BzkU-W_bTyW_AL>pSM@OAXM%2Ph&8OP3xf&^ffORv;EzX z|H%;l%0F>D@HY~uc8b30|8KV3o~pyhVII91Q=WkuV`(jrv=f-h3!yuz5sq1Cb@ zJ+1c7je;mDl}oWQ&}Da0^C~lyS(_m$>}2-YH*NtOnt=`Q(#qKXFKx*B8K|I3J0<4? zq}6qdDy%7iUj;k>xk<9l~KyFlO}I7iJtS_^qvQl52!9m<&3o?Epb*9)K!^yg*C-DptDDReAQCTTw&k2{|GBjWMP*$jW6 zP9=s?-}Cq>|27!xJQ#ASnY-EMFfJ^>#P!np^7@VV-KGSywwI1=Xl-2CeCI+k58m+B zn+n5cucm%%^pU=?;3;l>0L?gT~Fg2qQmst!0|T=Fd8DwPREGcpRc5H2jq4P>#-mNx5#J_=L#Z_m{4J~EL{zx~p*xCR>g3Ilh1YSKf+4kl*Sw$hu=+>GcxQ~fF^&br^(uZwrE?GUmvEP_lua7%Y_aq;%rM8A3&i)SA`Z}GEZj<}$f z8dht52GrCN^H*|-k;`a}VyT*$XzJcCdA=xnrJ8D4^KM=nhEP}lE=);C_J9M2GabfDg9@HIdz>ET zE73}yO78nlyc46%`39kQL2B0NTi*PaR9lkw1IktrzD9H8WJ?mBZpBPsiecTaXuCz>iL`LEIRG00GJ_Ml6MJ*k-?z8>9L za&E0Lel-rRM|CW2yh!#%DlVCe&FAWMlkgW-1^92Nf@;ZmGsJ4N$Zqk+q>&bkYQF?Q z?X9}|n!+J>GnqhMS1wgE7BZZ{v0q5`pN_{grZu~KV#JXIt<2g?{l0oSB(ZV61TCBu zgN9jm;Jq3k-~e?QB~2F1q9qB<2o~71x9J7_ofy-nnd!*BGvouHGKd#(bJN%81Cn_P~&L~&VBXR zN-j*utlfAWEh%rhR9bL%0;R(LK99Tu8QQ#!xLI;aC0c4Mt{`|V^^t{6=`#?dxLj4L zAUWlh=mPV5g%94amNj*4(_n29M2VUr1 zi8cAa8UjB@d=vxw^NoS_T@2@o+ka<( zN%ix9emZ~d>mpmtUzOTG=&W02pBZZo0iBkTx8kxSf1*8^T5weHWZ!yC9N_U|Yxe0s zCAXkS&d$L|r$@$>jjXGZENa!Bc_@1_I%xu>AVyZo)zookwU%}B5QBNbz_5k|nsd$1 z1*17ZHC*z2G$gfKMLM3gY6=u}GfH&PVWVjf_8Z~Z+-a_M<$EGE`d3|dR?WFt6(Q~x zl@5rX6TJZCYl$_E%LC$Ab>h|&`ghU)ba#^raPtdu9fvl0ZnQK|la(7snLE7|3LsRz z4Gl^5i)CdEBML7IXe+zyeF6F_g+}%W?7?e7 zNSaxU+6z5;daa%;Q$qhAJrguT0{5@A& zBWl60uH=TBiu6QZmw&oXTQNRZBj$ICA69W{_>fl7pFr{ezs75>p46gP?&lbCv`ld% z_MwT=;`VVNaud2Gf7vrszj&igPU`sf6u(v#w758r58eB1 zeKNrK+0!gaeKM8o@7oL988{Eqnund4Vo$@jBvtW8XAWJ^4demTH9b07YphjRlZ+xCI>>cb6T*nymnHk znr?JLP)`#y_qYJfu5uH}C{kYLD-iZ*)ezJBY6TWSSKtI)Ah*j$Lo>`uNkvDq*Oe_L z!E^*+sE`__Oh-^a_9C5kau!^j$*yoxrQ~5)c(Ml*fjyLrvZYWIIp)I7x_6Xx|3-3Z zltffWPgZIy$sRX37A_sx4REoftQTBifle18)X0}|~)08!o@90nch5J7*ANt0hJPrKe zMMbgfm((w2Eoe-6NgzZ53s z8@*#;SX{xKrX#{(c<}R3kwS!^+TT#)*bkLAC+9Ig^hbLXJK5Vx(7JDb&DD{#E|3&A)Yz!?Ep9cTm)pVA;*Q5uqA_4m z4p%x2QX^ax$T@Y8fcn`e3|%AD&%v~aJM1zN7w1b>yOv! z!%fY!0nTx!UH0Z;qG=jHbxM0GpD|XNquAEopOJfGs>Ca8;ZEmrDj@3+u?vCd9}xcGaN15`;9 zlmr`3l#KY*wfbYYqCPdDA_P*{&me0CHEKo997M_<8Q(Bz@vx1J#V{jdK($IdpzZqh z$;7UM4-a1r%C_Thd#KEWM$CYmy{;O&4>3xPCv+*<9rbBu%;a-Z_pqD#l<*XF(_ejE z$;>2u>YPy)d|w^dy~F^YjL*%#C6)eP6W1OORrlITUwJ3AEV?B6(y0XDCNcPhNAJRr04$o2An(h}FMH0R+lTtZSD($ax z;_%a6qJ4(JH?mm2pAW6Kr~gU$?81R|hHb*tI2ZA?C~#q^A=ojv1FT${{QJ^xJ=lYX z_X1SU1ANyH@H605j|++DSPpKn4_xq&Vll2yT1Xm?6}c3;x+>jQ6;*+3j zSlsnPKwKT#bRtjMzk2J(TS^LzxPg4IgEZGUbHBOid~5S}Oj;@mbhD}*$Dp4(Gn=o} z=Sxy|ybR%mW&*qd!R}M`77_iLgA0MG??|wPHaH$vfNi#>=6Ql#}NENmbpf+4CC?lG&L~xL2rbDr1y$R?6W1e#VSJ; z>yu8hENP^;Mp+F2W9@%D=kchwoi|1ah{^-C`oIFN+NUw|&0@TTiD<5U{&ckVSM*>F z@qQQCQntuXu?-GgLq?;sY_5iQvb&q2H1=Bs!d8qP{P;1kPLlxQp_~;8+JLQ^Ft*;q zP`iC#F0)BtB#Li-YqC1VX|bDFzDwI?ufhh7jAG6M#6f#HPQ^a`#YJoU5dy(9VPiqK z^Yr~D_h_2x3j&QJad|YomHta%B)LBSLqoA-AH1c^Iuag?9!OdVwt0!mo}>K5`MtK! zXwUZGIv8`$5?Yvc3u3ReV9>fYJ(;ZP9wwY-efSGUG$+fGq{mHo|IktsL(tndE@5j8 ze0*nRMYQ8R8vf8#@gFmp#Jjtv&fq2pDL&>h8E4V}3Nbr&v)yz4K}ba%8O<7#P~MvJ z5dQJk+hPvIHM1$IPB2c3;=WWktt#3jaI+9pSw=&9sT0}oQf6D5Cit0ss;akCGO8z{ z;eysS=;vP`{uHI$8h!1Wt{KN(fgFvxEIceZPoA;n!R0(vUnX7Ys}~lbz)3)J9Qs#( zw<90x>>U((RqvdO$QLsd6ttW*U4y3_LB$YU$o(((h~@5%4I=;@JMT4PGL++*n}C9n zD5q~6m8IQbst!gc>L%qnzX`EJO3in2IL@6p2W^1?%DY~xDfIZ$wae83KiTte3vQ>Pz9Tax068FB)iZMp6STOJ99 zxAhEa+4MQhYpw}wc85e?W`{rPSV%1iJ!9Z85LPj(jeyK29MYZDIB`l}+vILj=>n?N z?SoGD{TFMOf9f>2yRo@8+vP(4K?-QU%uQ1|=T;yB4`RL=Ro6CeIE41jg*q%#G4*8-KP7!m$x za5A1^kjNS~KK7)rleZQej+A@PXga96@vgo^xBk^3Oa*p5g!L#G@gMMI$UVppux>4KuN)bPB$pbZlA4g-bAIMBN4-9BRVUNP$LIKp zTuh_?;+c=X4!Ir-G=aU#)2HB)Ih`OyrDXM#Dt|P?Bf9WhBAS#BI+Emd zUWr&cDf9KW2MgfAISYxm-BeH9*x9}ro{@~oqC^Q6Y98KG8Ay55`QAinBb81rSvz7h3^+%TwVXTdp@PD4QweVlo=yj%*wCKG(55>1B1-cNiQ z-|QLX*xu;=t@3q4ysSBrpnf1j*rV0TIx)%f)Z~>dX^Wgr<;u6`AkOGG)dij>2UDyq=RDNoRFy_C)w1zr4KI zGHsLIV|H=(k?5H$qXw09#}k?zQ-$!hcS}5OllTLKPX-{lWLHl4!#gnz0O;0&&p!b8 z8CqRc>Ya&_5`(o=#2C<8yXGPF9Qi6jq5!%tApQZ0>ldvK%vJ!HEPyQofJxvNgeP1H zV740J5e$S!{Qn`oI7S%sKU4d#yZwFPjjt`#U@MZyl}`-N_jX^&HKfJC62Q*d$*SQz GIrU$}Xvg>f diff --git a/src/lib/gateway.js b/src/lib/gateway.js index e62d778..8ec55b6 100644 --- a/src/lib/gateway.js +++ b/src/lib/gateway.js @@ -38,12 +38,14 @@ function createGateway({ /** Accept any enabled gateway API key (multi-key). */ function validKeys(cfg) { const keys = new Set(); - if (Array.isArray(cfg.apiKeys)) { + if (Array.isArray(cfg.apiKeys) && cfg.apiKeys.length) { for (const k of cfg.apiKeys) { if (k && k.enabled !== false && k.key) keys.add(String(k.key).trim()); } + } else if (cfg.apiKey) { + // Legacy configurations are migrated to apiKeys on load. + keys.add(String(cfg.apiKey).trim()); } - if (cfg.apiKey) keys.add(String(cfg.apiKey).trim()); return keys; } diff --git a/src/lib/ipc-security.js b/src/lib/ipc-security.js new file mode 100644 index 0000000..5362b2e --- /dev/null +++ b/src/lib/ipc-security.js @@ -0,0 +1,67 @@ +"use strict"; + +const LOCKED_CHANNELS = new Set([ + "app:get-state", + "app:verify-admin-password", + "app:hide-panel", + "app:quit", +]); + +const EXTERNAL_HOSTS = new Set(["rerouted.dev", "www.rerouted.dev"]); + +function hasAdminPassword(cfg) { + return !!cfg?.adminPasswordHash && cfg.adminPasswordHash !== "harness"; +} + +function isUnlocked(cfg, sessionAuth, { harness = false } = {}) { + return !!harness || sessionAuth.isUnlocked(hasAdminPassword(cfg)); +} + +function canInvoke(channel, cfg, sessionAuth, options) { + if (!cfg?.onboardingComplete || !hasAdminPassword(cfg)) return true; + if (isUnlocked(cfg, sessionAuth, options)) return true; + return LOCKED_CHANNELS.has(channel); +} + +function lockedError() { + return { + ok: false, + code: "rerouted_locked", + error: "Unlock ReRouted to continue.", + }; +} + +function isAllowedExternalUrl(value) { + try { + const url = new URL(String(value || "")); + return url.protocol === "https:" && EXTERNAL_HOSTS.has(url.hostname.toLowerCase()); + } catch { + return false; + } +} + +function redactLockedState(state) { + if (!state?.hasAdminPassword || state.unlocked || !state.onboardingComplete) return state; + return { + onboardingComplete: true, + onboardingStep: "done", + appVersion: state.appVersion, + update: state.update, + port: state.port, + serverEnabled: state.serverEnabled, + serverListening: state.serverListening, + unlocked: false, + hasAdminPassword: true, + steps: state.steps || [], + }; +} + +module.exports = { + LOCKED_CHANNELS, + hasAdminPassword, + isUnlocked, + canInvoke, + lockedError, + isAllowedExternalUrl, + redactLockedState, +}; diff --git a/src/lib/logger.js b/src/lib/logger.js index a969a24..8697aac 100644 --- a/src/lib/logger.js +++ b/src/lib/logger.js @@ -4,10 +4,85 @@ const fs = require("node:fs"); const path = require("node:path"); const MAX = 500; +const REDACTED = "[REDACTED]"; const entries = []; let filePath = null; let seq = 0; +const SAFE_TOKEN_KEYS = new Set([ + "cachedtokens", + "inputtokens", + "maxtokens", + "outputtokens", + "tokencount", + "totaltokens", +]); + +function isSensitiveKey(key) { + const normalized = String(key || "").replace(/[^a-z0-9]/gi, "").toLowerCase(); + if (!normalized) return false; + if (SAFE_TOKEN_KEYS.has(normalized)) return false; + if (["authorization", "proxyauthorization", "cookie", "setcookie"].includes(normalized)) { + return true; + } + if ( + [ + "authorizationcode", + "codeprefix", + "codeverifier", + "oauthcode", + "oauthstate", + ].includes(normalized) + ) { + return true; + } + if (normalized.includes("apikey") || normalized.includes("credential")) return true; + if (normalized.includes("password") || normalized.includes("passphrase")) return true; + if (normalized.includes("secret") || normalized.includes("privatekey")) return true; + return normalized === "token" || normalized === "tokens" || normalized.endsWith("token"); +} + +function redactString(value) { + return String(value || "") + .replace(/(\b(?:cookie|set-cookie)\s*:\s*)[^\r\n]+/gi, `$1${REDACTED}`) + .replace( + /(\b(?:authorization|proxy-authorization|x-api-key|api-key)\s*[:=]\s*)(?:Bearer\s+)?([^\s,;"']+)/gi, + `$1${REDACTED}` + ) + .replace(/(\bBearer\s+)([^\s,;"']+)/gi, `$1${REDACTED}`) + .replace( + /([?&#]|\b)(code|state|token|password|passphrase|secret|access_token|refresh_token|id_token|api_key|client_secret|code_verifier)=([^&#\s]+)/gi, + `$1$2=${REDACTED}` + ) + .replace( + /(["']?(?:access_token|accessToken|refresh_token|refreshToken|id_token|idToken|api_key|apiKey|client_secret|clientSecret|code_verifier|codeVerifier)["']?\s*:\s*["'])([^"']+)(["'])/g, + `$1${REDACTED}$3` + ) + .replace( + /(["']?(?:authorization|proxy_authorization|proxyAuthorization|cookie|set_cookie|setCookie|token|password|passphrase|secret|private_key|privateKey)["']?\s*:\s*["'])([^"']+)(["'])/gi, + `$1${REDACTED}$3` + ) + .replace(/\brr-[a-f0-9]{16,}\b/gi, REDACTED) + .replace(/\bsk-[a-z0-9_-]{12,}\b/gi, REDACTED) + .replace(/\beyJ[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\b/gi, REDACTED); +} + +function redactValue(value, seen = new WeakSet()) { + if (typeof value === "string") return redactString(value); + if (!value || typeof value !== "object") return value; + if (seen.has(value)) return "[Circular]"; + seen.add(value); + + if (Array.isArray(value)) return value.map((item) => redactValue(item, seen)); + if (value instanceof Date) return value.toISOString(); + + const result = {}; + for (const [key, item] of Object.entries(value)) { + result[key] = isSensitiveKey(key) ? REDACTED : redactValue(item, seen); + } + return result; +} + function configure(logFile) { filePath = logFile || null; if (filePath) { @@ -29,8 +104,8 @@ function log(level, msg, meta) { id: ++seq, at: Date.now(), level: level || "info", - msg: String(msg || ""), - meta: meta && typeof meta === "object" ? meta : undefined, + msg: redactString(msg), + meta: meta && typeof meta === "object" ? redactValue(meta) : undefined, }; entries.unshift(entry); if (entries.length > MAX) entries.length = MAX; @@ -58,12 +133,12 @@ function formatLine(e) { let extra = ""; if (e.meta) { try { - extra = " " + JSON.stringify(e.meta); + extra = " " + JSON.stringify(redactValue(e.meta)); } catch { extra = " [meta]"; } } - return `[${ts}] [${e.level}] ${e.msg}${extra}`; + return `[${ts}] [${e.level}] ${redactString(e.msg)}${extra}`; } function list(limit = 200) { @@ -104,4 +179,7 @@ module.exports = { oauth, debug, formatLine, + isSensitiveKey, + redactString, + redactValue, }; diff --git a/src/lib/model-test.js b/src/lib/model-test.js index b70483e..eb695fe 100644 --- a/src/lib/model-test.js +++ b/src/lib/model-test.js @@ -1,5 +1,15 @@ "use strict"; +const { redactString } = require("./logger"); + +const MAX_ERROR_BODY_LENGTH = 4096; + +function safeErrorBody(value, maxLength = MAX_ERROR_BODY_LENGTH) { + const body = redactString(value); + if (body.length <= maxLength) return body; + return `${body.slice(0, maxLength)}... [truncated ${body.length - maxLength} chars]`; +} + function hasErrorValue(value) { if (typeof value === "string") return value.trim().length > 0; if (value && typeof value === "object") return Object.keys(value).length > 0; @@ -53,22 +63,26 @@ async function inspectModelTestResponse(response) { try { body = await response.text(); } catch (error) { - body = error?.message || String(error); + body = safeErrorBody(error?.message || String(error)); return { ok: false, status: response.status || null, body }; } body = String(body || ""); if (!response.ok) { - return { ok: false, status: response.status || null, body: body || response.statusText || "" }; + return { + ok: false, + status: response.status || null, + body: safeErrorBody(body || response.statusText || ""), + }; } if (bodyHasUpstreamError(body)) { - return { ok: false, status: response.status || 200, body }; + return { ok: false, status: response.status || 200, body: safeErrorBody(body) }; } return { ok: true, status: response.status || 200, body }; } function logFailure(logger, label, status, body) { - logger?.error?.(`Model test failed for ${label}`, { status, body }); + logger?.error?.(`Model test failed for ${label}`, { status, body: safeErrorBody(body) }); } async function runProviderModelTest({ adapter, provider, model, onTokenRefresh, logger } = {}) { @@ -99,15 +113,17 @@ async function runProviderModelTest({ adapter, provider, model, onTokenRefresh, } return { ok: true }; } catch (error) { - const message = error?.message || String(error); + const message = safeErrorBody(error?.message || String(error)); logFailure(logger, label, error?.status || null, message); return { ok: false, error: `Model test failed: ${message}` }; } } module.exports = { + MAX_ERROR_BODY_LENGTH, bodyHasUpstreamError, inspectModelTestResponse, payloadHasUpstreamError, runProviderModelTest, + safeErrorBody, }; diff --git a/src/lib/oauth.js b/src/lib/oauth.js index 9055958..722bd5c 100644 --- a/src/lib/oauth.js +++ b/src/lib/oauth.js @@ -33,6 +33,57 @@ function generatePkce(verifierBytes = 32) { return { codeVerifier, codeChallenge, state }; } +function statesMatch(expected, actual) { + const left = Buffer.from(String(expected || "")); + const right = Buffer.from(String(actual || "")); + return left.length > 0 && left.length === right.length && crypto.timingSafeEqual(left, right); +} + +function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function callbackPathMatches(actualPath, expectedPath) { + const normalize = (value) => { + const path = String(value || "/"); + return path.length > 1 ? path.replace(/\/+$/, "") : path; + }; + return normalize(actualPath) === normalize(expectedPath); +} + +function parseOAuthCallback(requestUrl, { baseUrl, callbackPath, expectedState } = {}) { + const url = new URL(requestUrl || "/", baseUrl || "http://127.0.0.1"); + if (!callbackPathMatches(url.pathname, callbackPath)) { + return { ok: false, status: 404, error: "Not found" }; + } + + const code = url.searchParams.get("code") || ""; + const state = url.searchParams.get("state") || ""; + const providerError = url.searchParams.get("error") || ""; + const errorDescription = url.searchParams.get("error_description") || ""; + if (!code && !providerError) { + return { ok: false, status: 400, error: "Missing OAuth result" }; + } + if (!statesMatch(expectedState, state)) { + return { ok: false, status: 400, error: "OAuth state mismatch. Start the connection again." }; + } + + return { + ok: true, + status: 200, + code, + state, + providerError, + errorDescription, + fullUrl: new URL(url.pathname + url.search, baseUrl || url.origin).toString(), + }; +} + const pending = new Map(); function getPending(type) { @@ -88,54 +139,61 @@ function normalizeAuthCode(raw) { return { code: s, state: "" }; } -function startCallbackServer(port, callbackPath, onCode) { +function callbackPage(result) { + const safeUrl = escapeHtml(result.fullUrl); + if (result.providerError) { + return `ReRouted authorization +

ReRouted

+

Authorization failed

+

${escapeHtml(result.errorDescription || result.providerError)}

+

Copy this URL and check Logs if it keeps failing:

+
${safeUrl}
+ `; + } + return `ReRouted authorization +

ReRouted - Authorization successful

+

Return to ReRouted and click Finish connection.

+

If the app did not pick up the code automatically, copy this full URL and paste it there:

+
${safeUrl}
+ `; +} + +function writeCallbackHtml(res, status, html) { + res.writeHead(status, { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'", + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", + }); + res.end(html); +} + +function startCallbackServer(port, callbackPath, expectedState, onCode) { return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { try { - const u = new URL(req.url || "/", `http://localhost:${port}`); - const code = u.searchParams.get("code"); - const state = u.searchParams.get("state"); - const error = u.searchParams.get("error"); - const errorDescription = u.searchParams.get("error_description"); - if (!code && !error) { - if (u.pathname !== callbackPath && u.pathname !== callbackPath.replace(/\/$/, "")) { - res.writeHead(404); - res.end("Not found"); - return; - } - } - // Show the full callback URL so it can be pasted back into the app. - const fullUrl = `http://localhost:${port}${u.pathname}${u.search}`; - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - if (error) { - res.end( - ` -

ReRouted

-

Authorization failed

-

${String(errorDescription || error)}

-

Copy this URL and check Logs if it keeps failing:

-
${fullUrl}
- ` - ); - } else { - res.end( - ` -

ReRouted — Authorization successful

-

Return to ReRouted and click I'm done.

-

If the app didn't pick up the code automatically, copy this full URL and paste it there:

-
${fullUrl}
- ` + const result = parseOAuthCallback(req.url, { + baseUrl: `http://localhost:${port}`, + callbackPath, + expectedState, + }); + if (!result.ok) { + writeCallbackHtml( + res, + result.status, + `

ReRouted

${escapeHtml(result.error)}

` ); + return; } + writeCallbackHtml(res, 200, callbackPage(result)); onCode({ - code, - state, - error, - error_description: errorDescription, + code: result.code, + state: result.state, + error: result.providerError, + error_description: result.errorDescription, }); } catch { - res.writeHead(500); - res.end("Error"); + writeCallbackHtml(res, 500, "

OAuth callback error.

"); } }); server.on("error", reject); @@ -144,28 +202,38 @@ function startCallbackServer(port, callbackPath, onCode) { } /** Prefer fixed port; on EADDRINUSE bind ephemeral and return actual port. */ -async function startCallbackServerFlexible(preferredPort, callbackPath, onCode) { +async function startCallbackServerFlexible(preferredPort, callbackPath, expectedState, onCode) { try { - const server = await startCallbackServer(preferredPort, callbackPath, onCode); + const server = await startCallbackServer(preferredPort, callbackPath, expectedState, onCode); return { server, port: preferredPort }; } catch { const server = await new Promise((resolve, reject) => { const s = http.createServer((req, res) => { try { - const u = new URL(req.url || "/", "http://127.0.0.1"); - const code = u.searchParams.get("code"); - const state = u.searchParams.get("state"); - const error = u.searchParams.get("error"); - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end( - `

ReRouted

${ - error ? "Authorization failed" : "Return to ReRouted and click I'm done." - }

` - ); - onCode({ code, state, error }); + const address = s.address(); + const activePort = typeof address === "object" && address ? address.port : 0; + const result = parseOAuthCallback(req.url, { + baseUrl: `http://127.0.0.1:${activePort}`, + callbackPath, + expectedState, + }); + if (!result.ok) { + writeCallbackHtml( + res, + result.status, + `

ReRouted

${escapeHtml(result.error)}

` + ); + return; + } + writeCallbackHtml(res, 200, callbackPage(result)); + onCode({ + code: result.code, + state: result.state, + error: result.providerError, + error_description: result.errorDescription, + }); } catch { - res.writeHead(500); - res.end("Error"); + writeCallbackHtml(res, 500, "

OAuth callback error.

"); } }); s.on("error", reject); @@ -285,7 +353,7 @@ async function startOAuth(type) { ...pkce, redirectUri: fixedRedirect, code: null, - pastedState: null, + callbackState: null, error: null, server: null, needsPaste: type === "claude" || type === "xai", @@ -303,6 +371,7 @@ async function startOAuth(type) { const { server, port } = await startCallbackServerFlexible( preferredPort, callbackPath, + session.state, ({ code, state, error }) => { if (error) { session.error = error; @@ -315,7 +384,7 @@ async function startOAuth(type) { hasState: !!state, }); } - if (state) session.pastedState = state; + if (state) session.callbackState = state; } ); session.server = server; @@ -370,13 +439,12 @@ async function startOAuth(type) { if (type === "chatgpt") pending.set("codex", session); if (type === "codex") pending.set("chatgpt", session); - // Parse params for the log (so users can see each field) + // Keep enough diagnostic structure without persisting live OAuth values. let paramDump = {}; try { const u = new URL(authUrl); - for (const [k, v] of u.searchParams) { - // don't dump full verifier-related secrets beyond challenge - paramDump[k] = v; + for (const [k] of u.searchParams) { + paramDump[k] = ["state", "nonce", "code_challenge"].includes(k) ? "[redacted]" : "[present]"; } } catch { /* ignore */ @@ -385,11 +453,11 @@ async function startOAuth(type) { logger.oauth(`AUTHORIZE URL ready type=${type}`, { redirectUri: session.redirectUri, needsPaste: !!session.needsPaste, - authUrl, + authorizeOrigin: new URL(authUrl).origin, params: paramDump, - altAuthUrl: altAuthUrl || undefined, + hasAltAuthUrl: !!altAuthUrl, }); - logger.info(`OAuth: open this URL for ${type}:\n${authUrl}`); + logger.info(`OAuth authorization URL prepared for ${type}`); return { authUrl, @@ -407,23 +475,24 @@ async function completeOAuth(type, { pasteCode, fetchImpl = fetch } = {}) { pasteLen: pasteCode ? String(pasteCode).length : 0, }); const session = pending.get(type) || pending.get(type === "codex" ? "chatgpt" : type); - if (!session && !pasteCode) { - logger.error("completeOAuth: no session and no paste code"); + if (!session) { + logger.error("completeOAuth: no active session"); throw new Error("No OAuth session in progress. Click the provider again."); } let code = session?.code; - let codeState = session?.pastedState || session?.state || ""; + let returnedState = session?.callbackState || ""; if (pasteCode) { const n = normalizeAuthCode(pasteCode); logger.oauth("paste code normalized", { rawLen: String(pasteCode).length, codeLen: n.code?.length || 0, stateLen: n.state?.length || 0, - codePrefix: (n.code || "").slice(0, 12), }); code = n.code || code; - if (n.state) codeState = n.state; + // A manual value must carry its own state; never combine a pasted code + // with state captured from an earlier callback. + returnedState = n.state || ""; } if (!code) { const msg = session?.error @@ -432,11 +501,15 @@ async function completeOAuth(type, { pasteCode, fetchImpl = fetch } = {}) { logger.error(msg); throw new Error(msg); } + if (!statesMatch(session.state, returnedState)) { + logger.warn(`OAuth state validation failed for ${type}`); + throw new Error("OAuth state mismatch. Start the connection again and paste the full callback URL."); + } const t = type === "codex" ? "chatgpt" : type; logger.oauth(`exchanging tokens type=${t}`, { codeLen: code.length, - stateLen: (codeState || "").length, + stateValidated: true, redirectUri: session?.redirectUri, }); let tokens; @@ -487,7 +560,7 @@ async function completeOAuth(type, { pasteCode, fetchImpl = fetch } = {}) { // Keep the token payload field order stable for the upstream endpoint. const tokenPayload = { code, - state: codeState || session?.state || "", + state: session.state, grant_type: "authorization_code", client_id: c.clientId, redirect_uri: ru, @@ -497,7 +570,7 @@ async function completeOAuth(type, { pasteCode, fetchImpl = fetch } = {}) { tokenUrl, redirect_uri: ru, hasCode: !!code, - hasState: !!(codeState || session?.state), + hasState: true, hasVerifier: !!session?.codeVerifier, }); const res = await fetchImpl(tokenUrl, { @@ -731,6 +804,9 @@ module.exports = { clearPending, buildAuthUrl, normalizeAuthCode, + statesMatch, + parseOAuthCallback, + escapeHtml, fetchClaudeProfile, backfillClaudeProfiles, }; diff --git a/src/lib/single-instance.js b/src/lib/single-instance.js new file mode 100644 index 0000000..466f4ac --- /dev/null +++ b/src/lib/single-instance.js @@ -0,0 +1,9 @@ +"use strict"; + +function acquireSingleInstance(app) { + const acquired = app.requestSingleInstanceLock(); + if (!acquired) app.exit(0); + return acquired; +} + +module.exports = { acquireSingleInstance }; diff --git a/src/lib/store.js b/src/lib/store.js index 1b7932c..680794a 100644 --- a/src/lib/store.js +++ b/src/lib/store.js @@ -12,6 +12,16 @@ const COMBO_NAME_MIGRATION_VERSION = 5; const XAI_LOCK_RESET_VERSION = 6; const OAUTH_ALIAS_RE = /^oauth([1-9]\d*)$/; +class ConfigLoadError extends Error { + constructor(message, { cause, filePath, recoveryPath } = {}) { + super(message, { cause }); + this.name = "ConfigLoadError"; + this.code = "CONFIG_LOAD_FAILED"; + this.filePath = filePath; + this.recoveryPath = recoveryPath || null; + } +} + function canonicalProviderType(type) { return type === "codex" ? "chatgpt" : type; } @@ -158,8 +168,8 @@ function migrate(cfg) { ]; cfg.apiKey = k; } else { - const primary = cfg.apiKeys.find((k) => k.enabled !== false) || cfg.apiKeys[0]; - if (primary) cfg.apiKey = primary.key; + const primary = cfg.apiKeys.find((k) => k.enabled !== false); + cfg.apiKey = primary?.key || ""; } if (!cfg.bindHost) cfg.bindHost = "127.0.0.1"; if (!cfg.providerAliasCounters || typeof cfg.providerAliasCounters !== "object") { @@ -207,18 +217,62 @@ function createStore(filePath) { fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); } + function preserveUnreadableConfig() { + const recoveryPath = `${filePath}.recovery-${Date.now()}-${process.pid}`; + try { + fs.copyFileSync(filePath, recoveryPath, fs.constants.COPYFILE_EXCL); + try { + fs.chmodSync(recoveryPath, 0o600); + } catch { + /* best effort; the original config is still untouched */ + } + return recoveryPath; + } catch { + return null; + } + } + + function configLoadError(error, detail) { + const recoveryPath = preserveUnreadableConfig(); + const recoveryDetail = recoveryPath + ? ` A recovery copy was saved at ${recoveryPath}.` + : " The original file was left untouched, but a recovery copy could not be created."; + return new ConfigLoadError( + `ReRouted could not load its existing configuration at ${filePath}: ${detail}.${recoveryDetail}`, + { cause: error, filePath, recoveryPath } + ); + } + function load() { if (cache) return cache; + let raw; try { - const raw = fs.readFileSync(filePath, "utf8"); - const parsed = JSON.parse(raw); - const before = JSON.stringify(parsed); - cache = migrate(parsed); - if (JSON.stringify(cache) !== before) save(cache); - } catch { + raw = fs.readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") { + throw configLoadError(error, error?.message || "the file could not be read"); + } cache = defaultConfig(); save(cache); + return cache; } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw configLoadError(error, "the file is not valid JSON"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw configLoadError( + new TypeError("Configuration root must be a JSON object"), + "the JSON root must be an object" + ); + } + + const before = JSON.stringify(parsed); + cache = migrate(parsed); + if (JSON.stringify(cache) !== before) save(cache); return cache; } @@ -226,7 +280,7 @@ function createStore(filePath) { ensureDir(); const next = migrate(cfg || cache || defaultConfig()); const primary = (next.apiKeys || []).find((k) => k.enabled !== false); - if (primary) next.apiKey = primary.key; + next.apiKey = primary?.key || ""; cache = next; const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`; fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 }); @@ -266,6 +320,7 @@ function createStore(filePath) { } module.exports = { + ConfigLoadError, createStore, defaultConfig, migrate, diff --git a/src/lib/usage.js b/src/lib/usage.js index 45a037d..e49b9d3 100644 --- a/src/lib/usage.js +++ b/src/lib/usage.js @@ -2,10 +2,12 @@ const fs = require("node:fs"); const path = require("node:path"); +const { DatabaseSync } = require("node:sqlite"); const { KEYED_PRESETS, OAUTH } = require("./constants"); -const MAX_EVENTS = 20_000; const RECENT_UI = 80; +const SCHEMA_VERSION = 1; +const LEGACY_MIGRATION_KEY = "legacy_usage_json_migrated"; const PERIODS = { "1h": 3600_000, @@ -88,158 +90,353 @@ function hydrateUsageIdentity(entry, providers = []) { }; } -/** - * Persistent + in-memory usage store for gateway requests. - */ -function createUsageStore(filePath) { - let events = []; - let loaded = false; +function numeric(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function normalizedRow(entry, at = Date.now()) { + return { + at, + model: entry?.model || null, + upstream: entry?.upstream || null, + providerId: entry?.providerId || null, + providerType: entry?.providerType || null, + providerName: entry?.providerName || null, + accountAlias: entry?.accountAlias || null, + status: numeric(entry?.status), + stream: !!entry?.stream, + prompt_tokens: numeric(entry?.prompt_tokens), + completion_tokens: numeric(entry?.completion_tokens), + cached_tokens: numeric(entry?.cached_tokens), + total_tokens: numeric(entry?.total_tokens), + error: entry?.error || null, + }; +} - function load() { - if (loaded) return; - loaded = true; +function insertValues(row, preservePayload = false) { + return [ + numeric(row.at), + row.model ?? null, + row.upstream ?? null, + row.providerId ?? null, + row.providerType ?? null, + row.providerName ?? null, + row.accountAlias ?? null, + numeric(row.status), + row.stream ? 1 : 0, + numeric(row.prompt_tokens), + numeric(row.completion_tokens), + numeric(row.cached_tokens), + numeric(row.total_tokens), + typeof row.error === "string" || row.error == null ? row.error : JSON.stringify(row.error), + preservePayload ? JSON.stringify(row) : null, + providerAggregateKey(row), + ]; +} + +function decodeRow(row) { + if (!row) return row; + if (typeof row.payload_json === "string") { try { - const raw = fs.readFileSync(filePath, "utf8"); - const data = JSON.parse(raw); - events = Array.isArray(data.events) ? data.events : []; + return JSON.parse(row.payload_json); } catch { - events = []; + // Reconstruct from indexed columns if a payload cannot be decoded. } } + return { + at: row.at, + model: row.model, + upstream: row.upstream, + providerId: row.provider_id, + providerType: row.provider_type, + providerName: row.provider_name, + accountAlias: row.account_alias, + status: row.status, + stream: !!row.stream, + prompt_tokens: row.prompt_tokens, + completion_tokens: row.completion_tokens, + cached_tokens: row.cached_tokens, + total_tokens: row.total_tokens, + error: row.error, + }; +} - function save() { - try { - fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); - const tmp = `${filePath}.${process.pid}.tmp`; - // Keep last MAX_EVENTS - if (events.length > MAX_EVENTS) events = events.slice(0, MAX_EVENTS); - fs.writeFileSync(tmp, JSON.stringify({ version: 1, events }, null, 0), { mode: 0o600 }); - fs.renameSync(tmp, filePath); - try { - fs.chmodSync(filePath, 0o600); - } catch { - /* ignore */ +function periodWhere(periodKey) { + const duration = PERIODS[periodKey]; + if (duration == null) return { sql: "", params: [] }; + return { sql: "WHERE at >= ?", params: [Date.now() - duration] }; +} + +function migrateLegacyJson(db, insert, legacyPath) { + if (!legacyPath || !fs.existsSync(legacyPath)) return 0; + const migrated = db.prepare("SELECT value FROM usage_meta WHERE key = ?").get(LEGACY_MIGRATION_KEY); + if (migrated) return 0; + + let legacy; + try { + legacy = JSON.parse(fs.readFileSync(legacyPath, "utf8")); + } catch (error) { + console.error(`usage migration failed: ${error.message}`); + return 0; + } + if (!Array.isArray(legacy?.events)) { + console.error("usage migration failed: legacy usage.json does not contain an events array"); + return 0; + } + + db.exec("BEGIN IMMEDIATE"); + try { + // Legacy JSON is newest-first. Reverse insertion preserves that order when + // timestamps are equal and SQLite uses the monotonically increasing id. + for (let index = legacy.events.length - 1; index >= 0; index--) { + const row = legacy.events[index]; + if (!row || typeof row !== "object" || Array.isArray(row)) { + throw new Error(`legacy usage row ${index} is not an object`); } - } catch (e) { - console.error("usage save failed:", e.message); + insert.run(...insertValues(row, true)); + } + db.prepare("INSERT INTO usage_meta (key, value) VALUES (?, ?)").run( + LEGACY_MIGRATION_KEY, + JSON.stringify({ rows: legacy.events.length, migratedAt: Date.now() }) + ); + db.exec("COMMIT"); + return legacy.events.length; + } catch (error) { + try { + db.exec("ROLLBACK"); + } catch { + // Preserve the original migration error. } + console.error(`usage migration failed: ${error.message}`); + return 0; } +} - function record(entry) { - load(); - const row = { - at: Date.now(), - model: entry.model || null, - upstream: entry.upstream || null, - providerId: entry.providerId || null, - providerType: entry.providerType || null, - providerName: entry.providerName || null, - accountAlias: entry.accountAlias || null, - status: entry.status || 0, - stream: !!entry.stream, - prompt_tokens: entry.prompt_tokens || 0, - completion_tokens: entry.completion_tokens || 0, - cached_tokens: entry.cached_tokens || 0, - total_tokens: entry.total_tokens || 0, - error: entry.error || null, +function isCorruptDatabaseError(error) { + return /file is not a database|database disk image is malformed|malformed database schema|database corrupt/i.test( + error?.message || "" + ); +} + +function recoveryPathFor(databasePath) { + const base = `${databasePath}.recovery-${Date.now()}`; + let candidate = base; + let suffix = 1; + while (fs.existsSync(candidate) || fs.existsSync(`${candidate}-wal`) || fs.existsSync(`${candidate}-shm`)) { + candidate = `${base}-${suffix++}`; + } + return candidate; +} + +function preserveCorruptDatabase(databasePath) { + const recoveryPath = recoveryPathFor(databasePath); + fs.renameSync(databasePath, recoveryPath); + for (const suffix of ["-wal", "-shm"]) { + const sidecar = `${databasePath}${suffix}`; + if (fs.existsSync(sidecar)) fs.renameSync(sidecar, `${recoveryPath}${suffix}`); + } + return recoveryPath; +} + +function initializeDatabase(db) { + db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA busy_timeout = 5000; + CREATE TABLE IF NOT EXISTS usage_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) STRICT; + CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY, + at INTEGER NOT NULL, + model TEXT, + upstream TEXT, + provider_id TEXT, + provider_type TEXT, + provider_name TEXT, + account_alias TEXT, + status INTEGER NOT NULL, + stream INTEGER NOT NULL CHECK (stream IN (0, 1)), + prompt_tokens INTEGER NOT NULL, + completion_tokens INTEGER NOT NULL, + cached_tokens INTEGER NOT NULL, + total_tokens INTEGER NOT NULL, + error TEXT, + payload_json TEXT, + provider_key TEXT NOT NULL + ) STRICT; + CREATE INDEX IF NOT EXISTS usage_events_at_idx ON usage_events (at DESC); + CREATE INDEX IF NOT EXISTS usage_events_model_aggregate_idx + ON usage_events (model, prompt_tokens, completion_tokens, cached_tokens); + CREATE INDEX IF NOT EXISTS usage_events_provider_aggregate_idx + ON usage_events ( + provider_key, provider_id, provider_type, provider_name, account_alias, + prompt_tokens, completion_tokens + ); + `); + db.prepare("INSERT OR IGNORE INTO usage_meta (key, value) VALUES ('schema_version', ?)").run( + String(SCHEMA_VERSION) + ); +} + +function openDatabase(databasePath) { + let db; + try { + db = new DatabaseSync(databasePath); + initializeDatabase(db); + return { db, recovery: null }; + } catch (error) { + try { + db?.close(); + } catch { + // Continue with recovery using the original initialization error. + } + if (!fs.existsSync(databasePath) || !isCorruptDatabaseError(error)) throw error; + + const recoveryPath = preserveCorruptDatabase(databasePath); + console.error( + `usage database was unreadable and has been preserved at ${recoveryPath}; ` + + "ReRouted started a fresh usage database. Keep the recovery file if historical data may be repairable." + ); + const fresh = new DatabaseSync(databasePath); + initializeDatabase(fresh); + return { + db: fresh, + recovery: { + reason: error.message, + recoveryPath, + }, }; - events.unshift(row); - if (events.length > MAX_EVENTS) events.length = MAX_EVENTS; - // Persist each event so recent activity survives an unexpected exit. - save(); - return row; } +} - function recent(limit = RECENT_UI) { - load(); - return events.slice(0, limit); +/** + * Persistent SQLite usage store for gateway requests. + */ +function createUsageStore(databasePath, { legacyPath } = {}) { + fs.mkdirSync(path.dirname(databasePath), { recursive: true, mode: 0o700 }); + const { db, recovery } = openDatabase(databasePath); + try { + fs.chmodSync(databasePath, 0o600); + } catch { + // The containing directory remains private if the platform rejects chmod. + } + + const insert = db.prepare(` + INSERT INTO usage_events ( + at, model, upstream, provider_id, provider_type, provider_name, account_alias, + status, stream, prompt_tokens, completion_tokens, cached_tokens, total_tokens, + error, payload_json, provider_key + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + migrateLegacyJson(db, insert, legacyPath); + + function record(entry) { + const row = normalizedRow(entry); + insert.run(...insertValues(row)); + return row; } - function inPeriod(periodKey) { - load(); - const ms = PERIODS[periodKey]; - if (ms == null) return events.slice(); - const cutoff = Date.now() - ms; - return events.filter((e) => e.at >= cutoff); + function recent(limit = RECENT_UI) { + const safeLimit = Math.max(0, Math.trunc(numeric(limit))); + return db + .prepare("SELECT * FROM usage_events ORDER BY at DESC, id DESC LIMIT ?") + .all(safeLimit) + .map(decodeRow); } function aggregate(periodKey = "24h") { - const rows = inPeriod(periodKey); - const byModel = new Map(); - const byProvider = new Map(); - let prompt = 0; - let completion = 0; - let cached = 0; - let total = 0; - let ok = 0; - let err = 0; - - for (const e of rows) { - prompt += e.prompt_tokens || 0; - completion += e.completion_tokens || 0; - cached += e.cached_tokens || 0; - total += e.total_tokens || 0; - if (e.status >= 200 && e.status < 400) ok++; - else err++; - - const mk = e.model || "unknown"; - const m = byModel.get(mk) || { - model: mk, - requests: 0, - prompt_tokens: 0, - completion_tokens: 0, - cached_tokens: 0, - }; - m.requests++; - m.prompt_tokens += e.prompt_tokens || 0; - m.completion_tokens += e.completion_tokens || 0; - m.cached_tokens += e.cached_tokens || 0; - byModel.set(mk, m); - - const pk = providerAggregateKey(e); - const p = byProvider.get(pk) || { - provider: providerAggregateLabel(e), - providerId: e.providerId || null, - providerType: canonicalProviderType(e.providerType) || null, - providerName: isInternalProviderName(e.providerName) ? null : e.providerName, - accountAlias: e.accountAlias || null, - requests: 0, - prompt_tokens: 0, - completion_tokens: 0, - }; - if (!p.providerType && e.providerType) p.providerType = canonicalProviderType(e.providerType); - if (!p.providerName && !isInternalProviderName(e.providerName)) p.providerName = e.providerName; - if (!p.accountAlias && e.accountAlias) p.accountAlias = e.accountAlias; - p.provider = providerAggregateLabel(p); - p.requests++; - p.prompt_tokens += e.prompt_tokens || 0; - p.completion_tokens += e.completion_tokens || 0; - byProvider.set(pk, p); - } + const period = periodWhere(periodKey); + const totals = db + .prepare(` + SELECT + COUNT(*) AS requests, + COALESCE(SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END), 0) AS ok, + COALESCE(SUM(CASE WHEN status >= 200 AND status < 400 THEN 0 ELSE 1 END), 0) AS errors, + COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0) AS completion_tokens, + COALESCE(SUM(cached_tokens), 0) AS cached_tokens, + COALESCE(SUM(total_tokens), 0) AS total_tokens + FROM usage_events ${period.sql} + `) + .get(...period.params); + + const byModel = db + .prepare(` + SELECT + COALESCE(model, 'unknown') AS model, + COUNT(*) AS requests, + COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0) AS completion_tokens, + COALESCE(SUM(cached_tokens), 0) AS cached_tokens + FROM usage_events ${period.sql} + GROUP BY COALESCE(model, 'unknown') + ORDER BY requests DESC, MAX(id) DESC + `) + .all(...period.params); + + // SQLite resolves the bare identity columns from the row selected by the + // query's single MAX(id), giving each group its newest identity cheaply. + const byProvider = db + .prepare(` + SELECT + provider_key, + NULLIF(provider_id, '') AS providerId, + CASE WHEN provider_type = 'codex' THEN 'chatgpt' ELSE NULLIF(provider_type, '') END AS providerType, + CASE + WHEN provider_name IS NULL OR trim(provider_name) = '' + OR lower(trim(provider_name)) LIKE 'prov\\_%' ESCAPE '\\' + THEN NULL + ELSE provider_name + END AS providerName, + NULLIF(account_alias, '') AS accountAlias, + COUNT(*) AS requests, + COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0) AS completion_tokens, + MAX(id) AS newest_id + FROM usage_events ${period.sql} + GROUP BY provider_key + ORDER BY requests DESC, newest_id DESC + `) + .all(...period.params) + .map(({ provider_key: _providerKey, newest_id: _newestId, ...entry }) => ({ + provider: providerAggregateLabel(entry), + ...entry, + })); + + const recentRows = db + .prepare(`SELECT * FROM usage_events ${period.sql} ORDER BY at DESC, id DESC LIMIT ?`) + .all(...period.params, RECENT_UI) + .map(decodeRow); return { period: periodKey, - requests: rows.length, - ok, - errors: err, - prompt_tokens: prompt, - completion_tokens: completion, - cached_tokens: cached, - total_tokens: total, - byModel: [...byModel.values()].sort((a, b) => b.requests - a.requests), - byProvider: [...byProvider.values()].sort((a, b) => b.requests - a.requests), - recent: rows.slice(0, RECENT_UI), + requests: totals.requests, + ok: totals.ok, + errors: totals.errors, + prompt_tokens: totals.prompt_tokens, + completion_tokens: totals.completion_tokens, + cached_tokens: totals.cached_tokens, + total_tokens: totals.total_tokens, + byModel, + byProvider, + recent: recentRows, }; } function totalsAllTime() { - load(); return { - allTimeRequests: events.length, + allTimeRequests: db.prepare("SELECT COUNT(*) AS count FROM usage_events").get().count, }; } - return { record, recent, aggregate, extractUsage, totalsAllTime, PERIODS }; + function close() { + db.close(); + } + + return { record, recent, aggregate, extractUsage, totalsAllTime, close, recovery, PERIODS }; } module.exports = { diff --git a/src/main.js b/src/main.js index d11fb6f..5810269 100644 --- a/src/main.js +++ b/src/main.js @@ -32,8 +32,16 @@ const { } = require("./lib/oauth"); const { createQuotaService } = require("./lib/quota"); const { createSessionAuth, isMacSessionActive } = require("./lib/session-auth"); +const { + canInvoke, + hasAdminPassword, + isAllowedExternalUrl, + lockedError, + redactLockedState, +} = require("./lib/ipc-security"); const { runProviderModelTest } = require("./lib/model-test"); const { createUpdateService } = require("./lib/updater"); +const { acquireSingleInstance } = require("./lib/single-instance"); const { KEYED_PRESETS, ONBOARDING_STEPS, DEFAULT_PORT, OAUTH } = require("./lib/constants"); const openaiCompat = require("./lib/providers/openai-compat"); const { defaultModelsForType, listProviderModels, getAdapter } = require("./lib/providers"); @@ -47,20 +55,20 @@ const { } = require("./lib/combos"); // ─── Paths / single instance ─────────────────────────────────────────── -const gotLock = app.requestSingleInstanceLock(); -if (!gotLock) { - app.quit(); -} +const gotLock = acquireSingleInstance(app); + +if (gotLock) { const userData = process.env.REROUTED_USER_DATA || app.getPath("userData"); const configPath = path.join(userData, "config.json"); -const usagePath = path.join(userData, "usage.json"); +const usagePath = path.join(userData, "usage.sqlite"); +const legacyUsagePath = path.join(userData, "usage.json"); const logPath = path.join(userData, "rerouted.log"); logger.configure(logPath); logger.info("ReRouted starting", { userData, logPath }); const store = createStore(configPath); -const usage = createUsageStore(usagePath); +const usage = createUsageStore(usagePath, { legacyPath: legacyUsagePath }); const router = createRouter({ store, usage }); const gateway = createGateway({ store, router }); const sessionAuth = createSessionAuth(); @@ -125,15 +133,20 @@ function setMacSessionUnlocked(value) { sessionAuth.setMacSessionUnlocked(value); if (panel && !panel.isDestroyed()) { const cfg = store.load(); - const hasPassword = !!cfg.adminPasswordHash && cfg.adminPasswordHash !== "harness"; + const hasPassword = hasAdminPassword(cfg); panel.webContents.send("app:session-lock-changed", { unlocked: sessionAuth.isUnlocked(hasPassword), }); } } +function harnessModeEnabled() { + return !app.isPackaged && !!process.env.REROUTED_STATE; +} + // Dev / harness state jump function applyStateEnv() { + if (!harnessModeEnabled()) return; const st = process.env.REROUTED_STATE; if (!st) return; if (st === "fresh") { @@ -202,9 +215,11 @@ function createPanel() { preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, - sandbox: false, + sandbox: true, }, }); + panel.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + panel.webContents.on("will-navigate", (event) => event.preventDefault()); panel.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); panel.loadFile(path.join(__dirname, "renderer", "index.html")); @@ -262,6 +277,17 @@ function updateTrayTitle() { // ─── IPC ─────────────────────────────────────────────────────────────── function registerIpc() { + const handle = (channel, handler) => { + ipcMain.handle(channel, async (...args) => { + const cfg = store.load(); + if (!canInvoke(channel, cfg, sessionAuth, { harness: harnessModeEnabled() })) { + logger.warn("Blocked locked IPC request", { channel }); + return lockedError(); + } + return handler(...args); + }); + }; + function publicActivityEntry(entry, cfg) { return { ...hydrateUsageIdentity(entry, cfg.providers), @@ -292,7 +318,7 @@ function registerIpc() { }; } - ipcMain.handle("app:get-state", async () => { + handle("app:get-state", async () => { const cfg = store.load(); const publicProviders = (cfg.providers || []).map((p) => { const models = listProviderModels(p, { includeDisabled: true }).map((m) => ({ @@ -324,9 +350,10 @@ function registerIpc() { createdAt: k.createdAt, })); // Primary key for simple copy on Home / onboarding - const primaryKey = - apiKeys.find((k) => k.enabled !== false)?.key || cfg.apiKey || ""; - return { + const primaryKey = apiKeys.find((k) => k.enabled !== false)?.key || ""; + const hasPassword = hasAdminPassword(cfg); + const unlocked = sessionAuth.isUnlocked(hasPassword) || harnessModeEnabled(); + return redactLockedState({ onboardingComplete: !!cfg.onboardingComplete, appVersion: app.getVersion(), update: updateService?.state() || { @@ -354,20 +381,18 @@ function registerIpc() { combos: (cfg.combos || []).map(publicCombo), stats: publicStats(router.stats(), cfg), usage: publicUsage(router.usageAggregate("24h"), cfg), - unlocked: sessionAuth.isUnlocked( - !!cfg.adminPasswordHash && cfg.adminPasswordHash !== "harness" - ) || !!process.env.REROUTED_STATE, - hasAdminPassword: !!cfg.adminPasswordHash && cfg.adminPasswordHash !== "harness", + unlocked, + hasAdminPassword: hasPassword, oauthProviders: Object.keys(OAUTH).map((k) => ({ id: k, name: OAUTH[k].name, })), keyedPresets: Object.values(KEYED_PRESETS), steps: ONBOARDING_STEPS, - }; + }); }); - ipcMain.handle("app:set-onboarding-step", async (_e, step) => { + handle("app:set-onboarding-step", async (_e, step) => { store.update((cfg) => { cfg.onboardingStep = step; if (step === "done") cfg.onboardingComplete = true; @@ -375,7 +400,7 @@ function registerIpc() { return { ok: true }; }); - ipcMain.handle("app:complete-onboarding", async () => { + handle("app:complete-onboarding", async () => { store.update((cfg) => { cfg.onboardingComplete = true; cfg.onboardingStep = "done"; @@ -383,7 +408,7 @@ function registerIpc() { return { ok: true }; }); - ipcMain.handle("app:set-open-at-login", async (_e, enabled) => { + handle("app:set-open-at-login", async (_e, enabled) => { app.setLoginItemSettings({ openAtLogin: !!enabled, openAsHidden: true }); store.update((cfg) => { cfg.openAtLogin = !!enabled; @@ -391,7 +416,7 @@ function registerIpc() { return { ok: true, openAtLogin: !!enabled }; }); - ipcMain.handle("app:set-admin-password", async (_e, password) => { + handle("app:set-admin-password", async (_e, password) => { if (!password || String(password).length < 4) { return { ok: false, error: "Password must be at least 4 characters" }; } @@ -403,7 +428,7 @@ function registerIpc() { return { ok: true }; }); - ipcMain.handle("app:verify-admin-password", async (_e, password) => { + handle("app:verify-admin-password", async (_e, password) => { const cfg = store.load(); if (!cfg.adminPasswordHash || cfg.adminPasswordHash === "harness") { sessionAuth.setManualUnlocked(true); @@ -414,7 +439,7 @@ function registerIpc() { return { ok }; }); - ipcMain.handle("app:change-admin-password", async (_e, { current, next }) => { + handle("app:change-admin-password", async (_e, { current, next }) => { const cfg = store.load(); if (cfg.adminPasswordHash && cfg.adminPasswordHash !== "harness") { const ok = await verifyPassword(current, cfg.adminPasswordHash); @@ -430,12 +455,12 @@ function registerIpc() { return { ok: true }; }); - ipcMain.handle("app:detect-providers", async () => { + handle("app:detect-providers", async () => { detectedCache = await detectAll(); return { ok: true, found: summarizeDetected(detectedCache) }; }); - ipcMain.handle("app:import-detected", async (_e, ids) => { + handle("app:import-detected", async (_e, ids) => { const want = new Set(ids || []); const toImport = detectedCache.filter((d) => want.has(d.id)); store.update((cfg) => { @@ -469,7 +494,7 @@ function registerIpc() { return { ok: true }; }); - ipcMain.handle("app:oauth-start", async (_e, type) => { + handle("app:oauth-start", async (_e, type) => { try { logger.oauth(`UI requested OAuth start for ${type}`); const result = await startOAuth(type); @@ -491,14 +516,14 @@ function registerIpc() { } }); - ipcMain.handle("app:oauth-status", async (_e, type) => oauthStatus(type)); + handle("app:oauth-status", async (_e, type) => oauthStatus(type)); - ipcMain.handle("app:oauth-cancel", async (_e, type) => { + handle("app:oauth-cancel", async (_e, type) => { clearPending(type); return { ok: true }; }); - ipcMain.handle("app:oauth-complete", async (_e, { type, pasteCode, providerId }) => { + handle("app:oauth-complete", async (_e, { type, pasteCode, providerId }) => { try { logger.oauth(`UI requested OAuth complete for ${type}`, { providerId: providerId || null, @@ -548,7 +573,7 @@ function registerIpc() { } }); - ipcMain.handle("app:logs-get", async (_e, limit) => { + handle("app:logs-get", async (_e, limit) => { return { ok: true, entries: logger.list(limit || 200), @@ -556,13 +581,13 @@ function registerIpc() { }; }); - ipcMain.handle("app:logs-clear", async () => { + handle("app:logs-clear", async () => { logger.clear(); logger.info("Logs cleared by user"); return { ok: true }; }); - ipcMain.handle("app:logs-reveal", async () => { + handle("app:logs-reveal", async () => { const f = logger.getFilePath(); if (f) { shell.showItemInFolder(f); @@ -571,7 +596,7 @@ function registerIpc() { return { ok: false, error: "No log file" }; }); - ipcMain.handle("app:add-keyed-provider", async (_e, payload) => { + handle("app:add-keyed-provider", async (_e, payload) => { const { preset, name, baseUrl, apiKey, accountId, models } = payload || {}; let finalBase = baseUrl; let finalName = name; @@ -599,7 +624,7 @@ function registerIpc() { return { ok: true, id: prov.id }; }); - ipcMain.handle("app:test-keyed-provider", async (_e, { baseUrl, apiKey }) => { + handle("app:test-keyed-provider", async (_e, { baseUrl, apiKey }) => { try { const models = await openaiCompat.listModels({ baseUrl, apiKey }); return { ok: true, models }; @@ -608,7 +633,7 @@ function registerIpc() { } }); - ipcMain.handle("app:remove-provider", async (_e, id) => { + handle("app:remove-provider", async (_e, id) => { store.update((cfg) => { cfg.providers = cfg.providers.filter((p) => p.id !== id); for (const c of cfg.combos) { @@ -618,7 +643,7 @@ function registerIpc() { return { ok: true }; }); - ipcMain.handle("app:set-provider-enabled", async (_e, { id, enabled }) => { + handle("app:set-provider-enabled", async (_e, { id, enabled }) => { let found = false; store.update((cfg) => { const p = cfg.providers.find((x) => x.id === id); @@ -630,7 +655,7 @@ function registerIpc() { return { ok: found, enabled: !!enabled }; }); - ipcMain.handle("app:usage", async (_e, period) => { + handle("app:usage", async (_e, period) => { const cfg = store.load(); return { ok: true, @@ -639,15 +664,15 @@ function registerIpc() { }; }); - ipcMain.handle("app:quota-get", async () => { + handle("app:quota-get", async () => { return { ok: true, quota: quota.current() }; }); - ipcMain.handle("app:quota-refresh", async () => { + handle("app:quota-refresh", async () => { return { ok: true, quota: await quota.refresh() }; }); - ipcMain.handle("app:save-combo", async (_e, combo) => { + handle("app:save-combo", async (_e, combo) => { const name = String(combo?.name || "").trim(); if (!name) return { ok: false, error: "Model ID is required" }; const current = store.load(); @@ -684,21 +709,21 @@ function registerIpc() { return { ok: true, combos: store.load().combos.map(publicCombo) }; }); - ipcMain.handle("app:delete-combo", async (_e, id) => { + handle("app:delete-combo", async (_e, id) => { store.update((cfg) => { cfg.combos = cfg.combos.filter((c) => !comboMatchesId(c, id)); }); return { ok: true }; }); - ipcMain.handle("app:set-server-enabled", async (_e, enabled) => { + handle("app:set-server-enabled", async (_e, enabled) => { store.update((cfg) => { cfg.serverEnabled = !!enabled; }); return { ok: true }; }); - ipcMain.handle("app:set-bind-host", async (_e, bindHost) => { + handle("app:set-bind-host", async (_e, bindHost) => { const h = bindHost === "0.0.0.0" ? "0.0.0.0" : "127.0.0.1"; store.update((cfg) => { cfg.bindHost = h; @@ -711,7 +736,7 @@ function registerIpc() { } }); - ipcMain.handle("app:create-api-key", async (_e, name) => { + handle("app:create-api-key", async (_e, name) => { const key = generateApiKey(); const entry = { id: generateId("key"), @@ -727,7 +752,7 @@ function registerIpc() { return { ok: true, key: entry }; }); - ipcMain.handle("app:revoke-api-key", async (_e, id) => { + handle("app:revoke-api-key", async (_e, id) => { store.update((cfg) => { cfg.apiKeys = (cfg.apiKeys || []).filter((k) => k.id !== id); if (!cfg.apiKeys.length) { @@ -736,21 +761,21 @@ function registerIpc() { { id: generateId("key"), key, name: "Default", createdAt: Date.now(), enabled: true }, ]; } - cfg.apiKey = cfg.apiKeys.find((k) => k.enabled !== false)?.key || cfg.apiKeys[0].key; + cfg.apiKey = cfg.apiKeys.find((k) => k.enabled !== false)?.key || ""; }); return { ok: true, apiKeys: store.load().apiKeys }; }); - ipcMain.handle("app:set-api-key-enabled", async (_e, { id, enabled }) => { + handle("app:set-api-key-enabled", async (_e, { id, enabled }) => { store.update((cfg) => { const k = (cfg.apiKeys || []).find((x) => x.id === id); if (k) k.enabled = !!enabled; - cfg.apiKey = cfg.apiKeys.find((x) => x.enabled !== false)?.key || cfg.apiKey; + cfg.apiKey = cfg.apiKeys.find((x) => x.enabled !== false)?.key || ""; }); return { ok: true }; }); - ipcMain.handle("app:set-model-enabled", async (_e, { providerId, modelId, enabled }) => { + handle("app:set-model-enabled", async (_e, { providerId, modelId, enabled }) => { store.update((cfg) => { const p = cfg.providers.find((x) => x.id === providerId); if (!p) return; @@ -768,7 +793,7 @@ function registerIpc() { return { ok: true }; }); - ipcMain.handle("app:add-model", async (_e, { providerId, modelId }) => { + handle("app:add-model", async (_e, { providerId, modelId }) => { const mid = String(modelId || "").trim(); if (!mid) return { ok: false, error: "Model name required" }; const cfg = store.load(); @@ -809,7 +834,7 @@ function registerIpc() { return { ok: true, modelId: mid }; }); - ipcMain.handle("app:remove-model", async (_e, { providerId, modelId }) => { + handle("app:remove-model", async (_e, { providerId, modelId }) => { store.update((cfg) => { const p = cfg.providers.find((x) => x.id === providerId); if (!p || !Array.isArray(p.models)) return; @@ -818,14 +843,17 @@ function registerIpc() { return { ok: true }; }); - ipcMain.handle("app:open-external", async (_e, url) => { + handle("app:open-external", async (_e, url) => { + if (!isAllowedExternalUrl(url)) { + return { ok: false, error: "That external link is not allowed." }; + } await shell.openExternal(url); return { ok: true }; }); - ipcMain.handle("app:update-check", async () => updateService?.check() || { ok: false }); + handle("app:update-check", async () => updateService?.check() || { ok: false }); - ipcMain.handle("app:update-install", async () => { + handle("app:update-install", async () => { if (!updateService || updateService.state().status !== "ready") { return { ok: false, error: "No downloaded update is ready" }; } @@ -833,16 +861,16 @@ function registerIpc() { return updateService.install(); }); - ipcMain.handle("app:hide-panel", async () => { + handle("app:hide-panel", async () => { hidePanel(); return { ok: true }; }); - ipcMain.handle("app:quit", async () => { + handle("app:quit", async () => { app.quit(); }); - ipcMain.handle("app:regenerate-key", async () => { + handle("app:regenerate-key", async () => { // Back-compat: create an additional key named Regenerated const key = generateApiKey(); const entry = { @@ -860,23 +888,6 @@ function registerIpc() { return { ok: true, apiKey: key }; }); - // Harness: force step without full reset - ipcMain.handle("harness:goto", async (_e, step) => { - if (step === "app" || step === "home") { - store.update((cfg) => { - cfg.onboardingComplete = true; - cfg.onboardingStep = "done"; - }); - sessionAuth.setManualUnlocked(true); - return { ok: true, page: "home" }; - } - store.update((cfg) => { - cfg.onboardingComplete = false; - cfg.onboardingStep = step; - }); - sessionAuth.setManualUnlocked(true); - return { ok: true, step }; - }); } // ─── Boot ────────────────────────────────────────────────────────────── @@ -885,8 +896,29 @@ app.whenReady().then(async () => { app.dock?.hide(); } - applyStateEnv(); - const identityConfig = store.load(); + let identityConfig; + try { + applyStateEnv(); + identityConfig = store.load(); + } catch (error) { + logger.error("ReRouted could not load its configuration", { + code: error?.code || null, + recoveryPath: error?.recoveryPath || null, + }); + await dialog.showMessageBox({ + type: "error", + title: "ReRouted could not open its configuration", + message: "Your existing accounts, keys, and routes were not overwritten.", + detail: + error?.message || + "ReRouted could not read config.json. Restore a valid configuration or move the damaged file before reopening the app.", + buttons: ["Quit ReRouted"], + defaultId: 0, + noLink: true, + }); + app.quit(); + return; + } if (backfillLocalOAuthIdentities(identityConfig.providers)) store.save(identityConfig); if (process.platform === "darwin") { let active = false; @@ -1004,3 +1036,4 @@ autoUpdater.on("before-quit-for-update", () => { // Export for tests / harness requiring main pieces module.exports = { store, router, gateway }; +} diff --git a/src/preload.js b/src/preload.js index e471813..5dddade 100644 --- a/src/preload.js +++ b/src/preload.js @@ -2,9 +2,68 @@ const { contextBridge, ipcRenderer } = require("electron"); +const INVOKE_CHANNELS = new Set([ + "app:get-state", + "app:set-onboarding-step", + "app:complete-onboarding", + "app:set-open-at-login", + "app:set-admin-password", + "app:verify-admin-password", + "app:change-admin-password", + "app:detect-providers", + "app:import-detected", + "app:oauth-start", + "app:oauth-status", + "app:oauth-cancel", + "app:oauth-complete", + "app:logs-get", + "app:logs-clear", + "app:logs-reveal", + "app:add-keyed-provider", + "app:test-keyed-provider", + "app:remove-provider", + "app:set-provider-enabled", + "app:usage", + "app:quota-get", + "app:quota-refresh", + "app:save-combo", + "app:delete-combo", + "app:set-server-enabled", + "app:set-bind-host", + "app:create-api-key", + "app:revoke-api-key", + "app:set-api-key-enabled", + "app:set-model-enabled", + "app:add-model", + "app:remove-model", + "app:open-external", + "app:update-check", + "app:update-install", + "app:hide-panel", + "app:quit", + "app:regenerate-key", + // Registered only by scripts/capture-ui.js; the production main process has no harness handler. + "harness:goto", + "harness:keyed-provider-adds", + "harness:oauth-cancel-races", + "harness:oauth-cancels", + "harness:set-update-state", +]); + +const EVENT_CHANNELS = new Set([ + "app:update-state", + "app:session-lock-changed", + "app:provider-identities-updated", + "app:open-settings", +]); + contextBridge.exposeInMainWorld("rerouted", { - invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), + invoke: (channel, ...args) => { + if (!INVOKE_CHANNELS.has(channel)) return Promise.reject(new Error("Unsupported IPC channel")); + return ipcRenderer.invoke(channel, ...args); + }, on: (channel, cb) => { + if (!EVENT_CHANNELS.has(channel)) throw new Error("Unsupported IPC channel"); const handler = (_e, ...a) => cb(...a); ipcRenderer.on(channel, handler); return () => ipcRenderer.removeListener(channel, handler); diff --git a/src/renderer/app.js b/src/renderer/app.js index 7243df0..b6c6f14 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -3,6 +3,7 @@ const api = window.rerouted; const { accountDisplayName, accountIdentityLabel, maskAccountEmail } = window.ReroutedAccountIdentity; +const { createLatestRequestGate, guardSensitiveRender } = window.ReroutedRendererLockState; const $ = (sel, el = document) => el.querySelector(sel); const view = $("#view"); const nav = $("#nav"); @@ -13,6 +14,7 @@ let page = "home"; let toastTimer = null; let armDelete = null; let activeProviderPanel = null; +const stateRequestGate = createLatestRequestGate(); function reducedMotion() { return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; @@ -68,7 +70,9 @@ function toast(msg) { } async function refresh() { - state = await api.invoke("app:get-state"); + const isLatest = stateRequestGate.begin(); + const nextState = await api.invoke("app:get-state"); + if (isLatest()) state = nextState; return state; } @@ -222,7 +226,13 @@ function wireSecrets(root = view) { }); } -const PASTE_CODE_PLACEHOLDER = "Paste code if prompted"; +const PASTE_CODE_PLACEHOLDER = "Paste the full callback URL if prompted"; +const OAUTH_RISK_NOTICE = + "Notice: This provider's subscription or OAuth session is not officially licensed for proxy or router use. Using it this way may result in account restrictions or bans. Proceed at your own risk."; + +function oauthRiskNotice() { + return `
${esc(OAUTH_RISK_NOTICE)}
`; +} function stepProgress(step) { const steps = state.steps || []; @@ -265,7 +275,7 @@ function renderAdminPassword() { view.innerHTML = ` ${stepProgress("admin-password")}

Create admin password

-

This password protects the settings panel on this Mac. It is stored as a scrypt hash — never sent anywhere.

+

Your active macOS login unlocks ReRouted. This password is the fallback when the app cannot confirm that session. It is stored as a scrypt hash — never sent anywhere.

@@ -286,7 +296,7 @@ function renderWelcome() { view.innerHTML = ` ${stepProgress("welcome")}
Your local wayfinder
-

Every model.
One clean route.

+

Your models.
One clean route.

Connect subscriptions and API keys once. ReRouted presents a single local endpoint and moves traffic when an account runs out.

Runs on this Mac
@@ -358,6 +368,7 @@ function renderOauthProviders() { ${stepProgress("oauth-providers")}

Connect OAuth providers

Click a provider to sign in with your browser. Multiple accounts per provider are supported — connect again to add another.

+ ${oauthRiskNotice()}
${list .map( @@ -376,10 +387,11 @@ function renderOauthProviders() { const type = btn.dataset.type; const panel = $("#oauth-panel"); panel.innerHTML = `
Signing in to ${esc(type)}…
-
Complete login in your browser, then click I'm done.
+
Complete login in your browser, then click Finish connection.
+ ${oauthRiskNotice()}
- +
`; @@ -586,7 +598,7 @@ function renderApiKeys() { view.innerHTML = ` ${stepProgress("api-keys")}

Add an API key

-

Optional. Quick-add a known OpenAI-compatible provider, or enter a custom base URL. Custom providers must pass Fetch models before Add.

+

Optional. Quick-add a known chat-completions provider, or enter a custom base URL. Custom providers must pass Fetch models before Add.

${keyedProviderPickerHtml(presets)}
@@ -621,7 +633,7 @@ let tutorialPage = 0; const TUTORIAL = [ { t: "What is ReRouted?", - b: "A local gateway. Your editors and agents send OpenAI-style chat completions to localhost; ReRouted talks to ChatGPT, Claude, Antigravity, xAI, and OpenAI-compatible APIs behind the scenes.", + b: "A local gateway. Your editors and agents send OpenAI-style chat completions to localhost; ReRouted talks to ChatGPT, Claude, Antigravity, xAI, and supported keyed APIs behind the scenes.", }, { t: "What are routes?", @@ -775,6 +787,10 @@ function renderLock() { }; } +function blockSensitiveRenderIfLocked() { + return guardSensitiveRender(state, render); +} + function fmtTime(at) { if (!at) return ""; try { @@ -888,6 +904,7 @@ function restartHomeRouteAnimation() { } function renderHome() { + if (blockSensitiveRenderIfLocked()) return; const { s, recent, online, last, lastLabel, u24, tokenTotal, errorRate } = homeValues(); homeLastRequestKey = homeRequestKey(last); view.innerHTML = ` @@ -926,6 +943,7 @@ function renderHome() { } function updateHome() { + if (blockSensitiveRenderIfLocked()) return; const root = view.querySelector("[data-home-root]"); if (!root) return renderHome(); const { s, recent, online, last, lastLabel, u24, tokenTotal, errorRate } = homeValues(); @@ -970,8 +988,9 @@ function startOauthFlow({ type, providerId, onDone, opener }) { const claudeHint = type === "claude" ? "After authorizing, paste the full localhost callback URL if the browser cannot return automatically." - : "Most providers return automatically. Paste a code only if the provider shows one."; + : "Most providers return automatically. Paste the full callback URL only if automatic return fails."; box.innerHTML = `
${providerId ? "Reconnect" : "New account"}
${esc(providerLabel(type))}
Opening a secure browser session. ${esc(claudeHint)}
+ ${oauthRiskNotice()}
Starting OAuth…
Having trouble? @@ -1073,6 +1092,7 @@ function startOauthFlow({ type, providerId, onDone, opener }) { } function renderProviders() { + if (blockSensitiveRenderIfLocked()) return; disposeProviderPanel({ clear: false }); const list = state.providers || []; view.innerHTML = ` @@ -1268,7 +1288,8 @@ function renderProviders() { const listO = state.oauthProviders || []; panel.innerHTML = `
-
New source
Connect an account
Use a subscription login or bring an OpenAI-compatible API key.
+
New source
Connect an account
Use a subscription login or bring an API key for a supported chat-completions provider.
+ ${oauthRiskNotice()}
${listO.map((p) => ``).join("")}
@@ -1297,7 +1318,7 @@ function renderProviders() { disposeProviderPanel({ clear: true }); panel.innerHTML = `
-
API source
Connect an API key
Choose a preset or bring any OpenAI-compatible endpoint. ReRouted tests it before adding its models.
+
API source
Connect an API key
Choose a preset or bring an endpoint that exposes the supported chat-completions shape. ReRouted tests it before adding its models.
${keyedProviderPickerHtml(presets)}
`; @@ -1345,6 +1366,7 @@ function comboMemberInfo(member, models) { } function renderCombos() { + if (blockSensitiveRenderIfLocked()) return; const combos = state.combos || []; const models = flatModels(); const editor = comboDraft; @@ -1507,15 +1529,21 @@ let statsPeriod = "24h"; let statsRenderRequest = 0; let logsRenderRequest = 0; let pollTimer = null; +let quotaRefreshTimer = null; let quotaState = null; let quotaLoading = false; let quotaInitialized = false; +const QUOTA_REFRESH_INTERVAL_MS = 60_000; function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + if (quotaRefreshTimer) { + clearInterval(quotaRefreshTimer); + quotaRefreshTimer = null; + } } function startPoll(fn, ms = 2500) { @@ -1614,19 +1642,30 @@ async function refreshQuota() { } async function initializeQuota() { - if (quotaInitialized) return; - quotaInitialized = true; - try { - const r = await api.invoke("app:quota-get"); - if (r?.ok) quotaState = r.quota; - } catch { - /* the refresh path below will surface provider-specific errors */ + if (!quotaInitialized) { + quotaInitialized = true; + try { + const r = await api.invoke("app:quota-get"); + if (r?.ok) quotaState = r.quota; + } catch { + /* the refresh path below will surface provider-specific errors */ + } } if (page === "quota") renderQuota(); - if (!quotaState?.refreshedAt) await refreshQuota(); + if (!quotaState?.refreshedAt || Date.now() - quotaState.refreshedAt >= QUOTA_REFRESH_INTERVAL_MS) { + await refreshQuota(); + } +} + +function startQuotaRefreshPoll() { + if (quotaRefreshTimer) clearInterval(quotaRefreshTimer); + quotaRefreshTimer = setInterval(() => { + if (page === "quota") refreshQuota(); + }, QUOTA_REFRESH_INTERVAL_MS); } function renderQuota() { + if (blockSensitiveRenderIfLocked()) return; const accounts = quotaState?.accounts || []; view.innerHTML = ` ${pageHeader("Capacity", "Accounts", "See the remaining subscription capacity and reset time for every connected account.", ``)} @@ -1644,6 +1683,7 @@ function renderQuota() { } async function renderStats() { + if (blockSensitiveRenderIfLocked()) return; const requestId = ++statsRenderRequest; const period = statsPeriod || "24h"; let usage = state.usage; @@ -1656,7 +1696,11 @@ async function renderStats() { } catch { /* keep cached */ } - if (page !== "stats" || requestId !== statsRenderRequest) return; + if ( + page !== "stats" || + requestId !== statsRenderRequest || + blockSensitiveRenderIfLocked() + ) return; usage = usage || { requests: 0, prompt_tokens: 0, @@ -1764,6 +1808,7 @@ async function renderStats() { } async function renderLogs() { + if (blockSensitiveRenderIfLocked()) return; const requestId = ++logsRenderRequest; let r; try { @@ -1774,7 +1819,11 @@ async function renderLogs() { } return; } - if (page !== "logs" || requestId !== logsRenderRequest) return; + if ( + page !== "logs" || + requestId !== logsRenderRequest || + blockSensitiveRenderIfLocked() + ) return; const entries = r?.entries || []; const file = r?.file || ""; view.innerHTML = ` @@ -1820,6 +1869,7 @@ async function renderLogs() { } function renderSettings() { + if (blockSensitiveRenderIfLocked()) return; const keys = state.apiKeys || []; const bindAll = state.bindHost === "0.0.0.0"; const update = state.update || { status: "idle", currentVersion: state.appVersion }; @@ -1866,7 +1916,7 @@ function renderSettings() { ${sectionHeader("Gateway", state.serverListening ? "Online" : "Offline")}
-
Serve the gateway
Accept OpenAI-compatible requests on /v1.
+
Serve the gateway
Accept supported chat-completions requests on /v1.
@@ -1909,7 +1959,7 @@ function renderSettings() {
Software updates
${esc(updateUi.copy)}
Quit ReRouted
Stops the menu bar app and local gateway.
-
· Released by .
+
· Independent personal project.
`; wireSecrets(); $("#copy-settings-url").onclick = () => copy(state.endpoint); @@ -1966,13 +2016,11 @@ function renderSettings() { ); if (result?.update) state.update = result.update; if (!result?.ok && result?.error) toast(result.error); - if (page === "settings") renderSettings(); + if (page === "settings") render(); }; $("#btn-quit").onclick = () => api.invoke("app:quit"); $("#btn-product-site").onclick = () => api.invoke("app:open-external", "https://rerouted.dev"); - $("#btn-public-bytes").onclick = () => - api.invoke("app:open-external", "https://publicbytes.org"); } function render() { @@ -2026,6 +2074,7 @@ function render() { renderQuota(); initializeQuota(); startPoll(updateQuotaCountdowns, 1000); + startQuotaRefreshPoll(); } else if (page === "stats") { renderStats(); @@ -2052,7 +2101,11 @@ nav.querySelectorAll(".nav-btn").forEach((btn) => { $("#btn-close").onclick = () => api.invoke("app:hide-panel"); -api.on("app:session-lock-changed", async () => { +api.on("app:session-lock-changed", async (session) => { + if (state && session?.unlocked === false) { + state.unlocked = false; + render(); + } await refresh(); render(); }); @@ -2060,7 +2113,7 @@ api.on("app:session-lock-changed", async () => { api.on("app:update-state", (update) => { if (!state) return; state.update = update; - if (page === "settings") renderSettings(); + if (page === "settings") render(); }); api.on("app:provider-identities-updated", async () => { diff --git a/src/renderer/index.html b/src/renderer/index.html index b6ed6ac..337331c 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -59,6 +59,7 @@
+ diff --git a/src/renderer/lock-state.js b/src/renderer/lock-state.js new file mode 100644 index 0000000..b48f0ef --- /dev/null +++ b/src/renderer/lock-state.js @@ -0,0 +1,35 @@ +"use strict"; + +(function exposeRendererLockState(root) { + function requiresLockScreen(state) { + return !!( + state?.onboardingComplete && + state.hasAdminPassword && + !state.unlocked + ); + } + + function guardSensitiveRender(state, renderLock) { + if (!requiresLockScreen(state)) return false; + renderLock(); + return true; + } + + function createLatestRequestGate() { + let latestRequest = 0; + return { + begin() { + const request = ++latestRequest; + return () => request === latestRequest; + }, + }; + } + + const api = { + requiresLockScreen, + guardSensitiveRender, + createLatestRequestGate, + }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + if (root) root.ReroutedRendererLockState = api; +})(typeof window !== "undefined" ? window : null); diff --git a/tests/gateway-key-disable.test.js b/tests/gateway-key-disable.test.js new file mode 100644 index 0000000..b52c672 --- /dev/null +++ b/tests/gateway-key-disable.test.js @@ -0,0 +1,40 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const os = require("node:os"); +const path = require("node:path"); +const { it } = require("node:test"); +const { createGateway } = require("../src/lib/gateway"); +const { createStore } = require("../src/lib/store"); + +it("revokes the legacy primary key when its apiKeys entry is disabled", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "rr-disabled-key-")); + const key = "rr-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const store = createStore(path.join(directory, "config.json")); + store.seed({ + onboardingComplete: true, + apiKey: key, + apiKeys: [{ id: "key_primary", key, name: "Primary", enabled: false }], + }); + + const gateway = createGateway({ + store, + router: { listModels: () => ({ object: "list", data: [] }) }, + port: 0, + }); + const server = http.createServer((req, res) => gateway.handle(req, res)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + assert.equal(store.load().apiKey, ""); + assert.deepEqual([...gateway.validKeys(store.load())], []); + const response = await fetch(`http://127.0.0.1:${server.address().port}/v1/models`, { + headers: { Authorization: `Bearer ${key}` }, + }); + assert.equal(response.status, 401); + } finally { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/ipc-security.test.js b/tests/ipc-security.test.js new file mode 100644 index 0000000..5a03b75 --- /dev/null +++ b/tests/ipc-security.test.js @@ -0,0 +1,82 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { describe, it } = require("node:test"); +const { createSessionAuth } = require("../src/lib/session-auth"); +const { + canInvoke, + isAllowedExternalUrl, + lockedError, + redactLockedState, +} = require("../src/lib/ipc-security"); + +describe("main-process IPC lock enforcement", () => { + const protectedConfig = { + onboardingComplete: true, + adminPasswordHash: "scrypt-hash", + }; + + it("allows only lock-screen actions until the session is authenticated", () => { + const auth = createSessionAuth({ platform: "linux" }); + assert.equal(canInvoke("app:get-state", protectedConfig, auth), true); + assert.equal(canInvoke("app:verify-admin-password", protectedConfig, auth), true); + assert.equal(canInvoke("app:remove-provider", protectedConfig, auth), false); + assert.equal(canInvoke("app:regenerate-key", protectedConfig, auth), false); + + auth.setManualUnlocked(true); + assert.equal(canInvoke("app:remove-provider", protectedConfig, auth), true); + }); + + it("does not block onboarding or password-free configurations", () => { + const auth = createSessionAuth({ platform: "linux" }); + assert.equal( + canInvoke("app:oauth-start", { onboardingComplete: false }, auth), + true + ); + assert.equal( + canInvoke("app:save-combo", { onboardingComplete: true }, auth), + true + ); + }); + + it("removes gateway keys and account state before returning locked state", () => { + const redacted = redactLockedState({ + onboardingComplete: true, + onboardingStep: "done", + appVersion: "0.4.2", + update: { status: "idle" }, + port: 4949, + serverEnabled: true, + serverListening: true, + unlocked: false, + hasAdminPassword: true, + apiKey: "rr-secret", + apiKeys: [{ key: "rr-secret" }], + providers: [{ email: "person@example.com" }], + combos: [{ name: "coding" }], + steps: ["done"], + }); + + assert.equal(redacted.apiKey, undefined); + assert.equal(redacted.apiKeys, undefined); + assert.equal(redacted.providers, undefined); + assert.equal(redacted.combos, undefined); + assert.equal(redacted.serverListening, true); + }); + + it("returns a stable machine-readable locked error", () => { + assert.deepEqual(lockedError(), { + ok: false, + code: "rerouted_locked", + error: "Unlock ReRouted to continue.", + }); + }); + + it("opens only the product's HTTPS destinations from the renderer", () => { + assert.equal(isAllowedExternalUrl("https://rerouted.dev"), true); + assert.equal(isAllowedExternalUrl("https://www.rerouted.dev/about"), true); + assert.equal(isAllowedExternalUrl("http://rerouted.dev"), false); + assert.equal(isAllowedExternalUrl("file:///tmp/secret"), false); + assert.equal(isAllowedExternalUrl("https://example.com"), false); + }); +}); diff --git a/tests/logger-redaction.test.js b/tests/logger-redaction.test.js new file mode 100644 index 0000000..175afb1 --- /dev/null +++ b/tests/logger-redaction.test.js @@ -0,0 +1,117 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { afterEach, it } = require("node:test"); +const logger = require("../src/lib/logger"); + +afterEach(() => { + logger.configure(null); + logger.clear(); +}); + +it("redacts credentials from messages, nested metadata, memory, and disk", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rr-logger-redaction-")); + const logPath = path.join(dir, "rerouted.log"); + const secrets = { + accessToken: "access-super-secret", + refreshToken: "refresh-super-secret", + apiKey: "rr-0123456789abcdef0123456789abcdef", + code: "oauth-code-secret", + state: "oauth-state-secret", + clientSecret: "client-secret-value", + bearer: "bearer-secret-value", + }; + logger.configure(logPath); + + const entry = logger.oauth( + `callback?code=${secrets.code}&state=${secrets.state} Authorization: Bearer ${secrets.bearer}`, + { + accessToken: secrets.accessToken, + nested: { + refresh_token: secrets.refreshToken, + apiKey: secrets.apiKey, + clientSecret: secrets.clientSecret, + oauthCode: secrets.code, + oauthState: secrets.state, + }, + metrics: { inputTokens: 12, output_tokens: 4 }, + } + ); + + const serialized = JSON.stringify(entry); + const disk = fs.readFileSync(logPath, "utf8"); + for (const secret of Object.values(secrets)) { + assert.equal(serialized.includes(secret), false); + assert.equal(disk.includes(secret), false); + } + assert.match(serialized, /\[REDACTED\]/); + assert.equal(entry.meta.metrics.inputTokens, 12); + assert.equal(entry.meta.metrics.output_tokens, 4); + assert.deepEqual(logger.list(1), [entry]); +}); + +it("redacts sensitive strings passed directly to formatLine", () => { + const line = logger.formatLine({ + at: 0, + level: "error", + msg: "x-api-key: sk-example123456789 and api_key=url-secret", + meta: { Authorization: "Bearer metadata-secret", password: "password-secret" }, + }); + + assert.equal(line.includes("sk-example123456789"), false); + assert.equal(line.includes("url-secret"), false); + assert.equal(line.includes("metadata-secret"), false); + assert.equal(line.includes("password-secret"), false); +}); + +it("redacts credential fields embedded in serialized upstream bodies", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwZXJzb24ifQ.signature123456"; + const body = JSON.stringify({ + access_token: "json-access-secret", + authorization: "Bearer json-bearer-secret", + token: "json-token-secret", + detail: jwt, + }); + const redacted = logger.redactString(body); + + assert.equal(redacted.includes("json-access-secret"), false); + assert.equal(redacted.includes("json-bearer-secret"), false); + assert.equal(redacted.includes("json-token-secret"), false); + assert.equal(redacted.includes(jwt), false); +}); + +it("handles circular metadata without retaining sensitive source objects", () => { + const meta = { apiKey: "rr-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }; + meta.self = meta; + const entry = logger.info("circular", meta); + + assert.equal(entry.meta.apiKey, "[REDACTED]"); + assert.equal(entry.meta.self, "[Circular]"); + meta.apiKey = "changed-after-log"; + assert.equal(JSON.stringify(entry).includes("changed-after-log"), false); +}); + +it("preserves ordinary provider error codes and application state labels", () => { + const entry = logger.error("provider failed", { + code: "usage_limit_reached", + state: "ready", + }); + + assert.equal(entry.meta.code, "usage_limit_reached"); + assert.equal(entry.meta.state, "ready"); +}); + +it("redacts cookie headers and generic sensitive URL parameters", () => { + const redacted = logger.redactString( + "Cookie: session=private; theme=dark\nSet-Cookie: auth=private2; Secure\n/callback?token=url-token&password=url-password&secret=url-secret" + ); + + assert.equal(redacted.includes("session=private"), false); + assert.equal(redacted.includes("auth=private2"), false); + assert.equal(redacted.includes("url-token"), false); + assert.equal(redacted.includes("url-password"), false); + assert.equal(redacted.includes("url-secret"), false); +}); diff --git a/tests/model-error.test.js b/tests/model-error.test.js index 7a2483d..5a69b86 100644 --- a/tests/model-error.test.js +++ b/tests/model-error.test.js @@ -4,6 +4,7 @@ const assert = require("node:assert/strict"); const { it } = require("node:test"); const openaiCompat = require("../src/lib/providers/openai-compat"); const { + MAX_ERROR_BODY_LENGTH, bodyHasUpstreamError, inspectModelTestResponse, runProviderModelTest, @@ -31,15 +32,18 @@ it("preserves the full provider model-test response", async () => { ); }); -it("rejects an HTTP-200 JSON model-test error without truncating it", async () => { +it("rejects an HTTP-200 JSON model-test error with a bounded response", async () => { const tail = "JSON-TAIL-MUST-REMAIN"; - const body = JSON.stringify({ error: { code: "bad_model", message: `${"x".repeat(600)}${tail}` } }); + const body = JSON.stringify({ + error: { code: "bad_model", message: `${"x".repeat(MAX_ERROR_BODY_LENGTH + 600)}${tail}` }, + }); const inspected = await inspectModelTestResponse(new Response(body, { status: 200 })); assert.equal(inspected.ok, false); assert.equal(inspected.status, 200); - assert.equal(inspected.body, body); - assert.match(inspected.body, new RegExp(tail)); + assert.ok(inspected.body.length < body.length); + assert.match(inspected.body, /\[truncated \d+ chars\]/); + assert.doesNotMatch(inspected.body, new RegExp(tail)); }); it("rejects an HTTP-200 SSE model-test error after metadata", async () => { @@ -78,19 +82,21 @@ it("accepts successful JSON and SSE model-test responses", async () => { assert.equal(bodyHasUpstreamError(sseBody), false); }); -it("preserves full non-OK model-test bodies", async () => { - const tail = "HTTP-TAIL-MUST-REMAIN"; - const body = `${"z".repeat(600)}${tail}`; +it("bounds and redacts non-OK model-test bodies", async () => { + const secret = "model-test-secret-token"; + const body = `Authorization: Bearer ${secret}\n${"z".repeat(MAX_ERROR_BODY_LENGTH + 600)}`; const inspected = await inspectModelTestResponse(new Response(body, { status: 400 })); assert.equal(inspected.ok, false); assert.equal(inspected.status, 400); - assert.equal(inspected.body, body); + assert.equal(inspected.body.includes(secret), false); + assert.match(inspected.body, /Authorization: \[REDACTED\]/); + assert.match(inspected.body, /\[truncated \d+ chars\]/); }); -it("logs thrown adapter model-test failures with the full message", async () => { - const tail = "THROWN-TAIL-MUST-REMAIN"; - const message = `${"q".repeat(600)}${tail}`; +it("bounds and redacts thrown adapter model-test failures", async () => { + const secret = "adapter-secret-token"; + const message = `api_key=${secret} ${"q".repeat(MAX_ERROR_BODY_LENGTH + 600)}`; const entries = []; const error = new Error(message); error.status = 401; @@ -102,8 +108,11 @@ it("logs thrown adapter model-test failures with the full message", async () => }); assert.equal(result.ok, false); - assert.match(result.error, new RegExp(tail)); + assert.equal(result.error.includes(secret), false); + assert.match(result.error, /\[REDACTED\]/); + assert.match(result.error, /\[truncated \d+ chars\]/); assert.equal(entries.length, 1); assert.equal(entries[0].meta.status, 401); - assert.equal(entries[0].meta.body, message); + assert.equal(entries[0].meta.body.includes(secret), false); + assert.match(entries[0].meta.body, /\[truncated \d+ chars\]/); }); diff --git a/tests/oauth-security.test.js b/tests/oauth-security.test.js new file mode 100644 index 0000000..874538f --- /dev/null +++ b/tests/oauth-security.test.js @@ -0,0 +1,143 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { describe, it } = require("node:test"); +const { + clearPending, + completeOAuth, + escapeHtml, + getPending, + parseOAuthCallback, + startOAuth, + statesMatch, +} = require("../src/lib/oauth"); + +describe("OAuth callback security", () => { + it("compares non-empty state values without accepting length or value mismatches", () => { + assert.equal(statesMatch("expected", "expected"), true); + assert.equal(statesMatch("expected", "attacker"), false); + assert.equal(statesMatch("expected", "short"), false); + assert.equal(statesMatch("", ""), false); + }); + + it("requires the exact callback path and expected state", () => { + const options = { + baseUrl: "http://localhost:54545", + callbackPath: "/callback", + expectedState: "expected-state", + }; + + assert.equal( + parseOAuthCallback("/wrong?code=abc&state=expected-state", options).status, + 404 + ); + assert.equal(parseOAuthCallback("/callback?code=abc", options).status, 400); + assert.equal( + parseOAuthCallback("/callback?code=abc&state=wrong-state", options).status, + 400 + ); + + const valid = parseOAuthCallback( + "/callback?code=abc&state=expected-state", + options + ); + assert.equal(valid.ok, true); + assert.equal(valid.code, "abc"); + assert.equal(valid.state, "expected-state"); + }); + + it("escapes provider-controlled callback text before rendering it", () => { + assert.equal( + escapeHtml(''), + "<img src=x onerror="alert(1)">" + ); + }); + + it("rejects a pasted callback with a mismatched state before token exchange", async () => { + await startOAuth("claude"); + let fetched = false; + try { + await assert.rejects( + completeOAuth("claude", { + pasteCode: "http://localhost:54545/callback?code=attacker-code&state=wrong-state", + fetchImpl: async () => { + fetched = true; + throw new Error("must not fetch"); + }, + }), + /OAuth state mismatch/ + ); + assert.equal(fetched, false); + } finally { + clearPending("claude"); + } + }); + + it("does not combine a plain pasted code with state from an earlier callback", async () => { + await startOAuth("antigravity"); + const session = getPending("antigravity"); + session.callbackState = session.state; + let fetched = false; + try { + await assert.rejects( + completeOAuth("antigravity", { + pasteCode: "unrelated-plain-code", + fetchImpl: async () => { + fetched = true; + throw new Error("must not fetch"); + }, + }), + /OAuth state mismatch/ + ); + assert.equal(fetched, false); + } finally { + clearPending("antigravity"); + } + }); + + it("completes state-bound ChatGPT, Antigravity, and xAI callback flows", async () => { + for (const type of ["chatgpt", "antigravity", "xai"]) { + const started = await startOAuth(type); + const session = getPending(type); + const callback = new URL(started.redirectUri); + callback.searchParams.set("code", `${type}-authorization-code`); + callback.searchParams.set("state", session.state); + const requests = []; + try { + const account = await completeOAuth(type, { + pasteCode: callback.toString(), + fetchImpl: async (url) => { + requests.push(String(url)); + if (String(url).includes("userinfo")) { + return { + ok: true, + async json() { + return { email: "person@example.com", name: "Example Person" }; + }, + }; + } + return { + ok: true, + async json() { + return { + access_token: `${type}-access-token`, + refresh_token: `${type}-refresh-token`, + expires_in: 3600, + }; + }, + async text() { + return ""; + }, + }; + }, + }); + + assert.equal(account.type, type); + assert.equal(account.accessToken, `${type}-access-token`); + assert.ok(requests.length >= 1); + } finally { + clearPending(type); + } + } + }); +}); diff --git a/tests/quota.test.js b/tests/quota.test.js index ad353c1..bb21c7e 100644 --- a/tests/quota.test.js +++ b/tests/quota.test.js @@ -200,4 +200,46 @@ describe("quota service", () => { assert.equal(snapshot.accounts[0].status, "ok"); assert.equal(snapshot.accounts[1].status, "unsupported"); }); + + it("coalesces overlapping refreshes into one provider probe", async () => { + let releaseFetch; + let fetchCalls = 0; + const fetchGate = new Promise((resolve) => { + releaseFetch = resolve; + }); + const service = createQuotaService({ + store: { + load: () => ({ + providers: [ + { + id: "p1", + type: "chatgpt", + name: "ChatGPT", + accessToken: "token", + enabled: true, + }, + ], + }), + }, + fetchImpl: async () => { + fetchCalls += 1; + await fetchGate; + return response({ + rate_limit: { + primary_window: { used_percent: 10, reset_at: 2_000_000_000 }, + }, + }); + }, + }); + + const first = service.refresh(); + const second = service.refresh(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(fetchCalls, 1); + + releaseFetch(); + const [firstSnapshot, secondSnapshot] = await Promise.all([first, second]); + assert.deepEqual(secondSnapshot, firstSnapshot); + assert.equal(fetchCalls, 1); + }); }); diff --git a/tests/renderer-lock.test.js b/tests/renderer-lock.test.js new file mode 100644 index 0000000..e147388 --- /dev/null +++ b/tests/renderer-lock.test.js @@ -0,0 +1,36 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { describe, it } = require("node:test"); +const { + createLatestRequestGate, + guardSensitiveRender, + requiresLockScreen, +} = require("../src/renderer/lock-state"); + +describe("renderer lock state", () => { + it("blocks sensitive page rendering only for a locked configured app", () => { + const locked = { + onboardingComplete: true, + hasAdminPassword: true, + unlocked: false, + }; + let lockRenders = 0; + + assert.equal(requiresLockScreen(locked), true); + assert.equal(guardSensitiveRender(locked, () => lockRenders++), true); + assert.equal(lockRenders, 1); + assert.equal(guardSensitiveRender({ ...locked, unlocked: true }, () => lockRenders++), false); + assert.equal(guardSensitiveRender({ ...locked, onboardingComplete: false }, () => lockRenders++), false); + assert.equal(lockRenders, 1); + }); + + it("rejects an older state response after a lock refresh starts", () => { + const gate = createLatestRequestGate(); + const acceptPreLockResponse = gate.begin(); + const acceptLockResponse = gate.begin(); + + assert.equal(acceptPreLockResponse(), false); + assert.equal(acceptLockResponse(), true); + }); +}); diff --git a/tests/single-instance.test.js b/tests/single-instance.test.js new file mode 100644 index 0000000..16d7d54 --- /dev/null +++ b/tests/single-instance.test.js @@ -0,0 +1,31 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { describe, it } = require("node:test"); +const { acquireSingleInstance } = require("../src/lib/single-instance"); + +describe("single-instance startup gate", () => { + it("exits a losing process before application initialization", () => { + const exits = []; + const acquired = acquireSingleInstance({ + requestSingleInstanceLock: () => false, + exit: (code) => exits.push(code), + }); + + assert.equal(acquired, false); + assert.deepEqual(exits, [0]); + }); + + it("keeps the primary process running", () => { + let exited = false; + const acquired = acquireSingleInstance({ + requestSingleInstanceLock: () => true, + exit: () => { + exited = true; + }, + }); + + assert.equal(acquired, true); + assert.equal(exited, false); + }); +}); diff --git a/tests/store-recovery.test.js b/tests/store-recovery.test.js new file mode 100644 index 0000000..7366bde --- /dev/null +++ b/tests/store-recovery.test.js @@ -0,0 +1,76 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { describe, it } = require("node:test"); +const { ConfigLoadError, createStore } = require("../src/lib/store"); + +function tempConfig() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rr-store-recovery-")); + return path.join(dir, "config.json"); +} + +describe("config load recovery", () => { + it("initializes a missing config file", () => { + const filePath = tempConfig(); + const config = createStore(filePath).load(); + + assert.equal(fs.existsSync(filePath), true); + assert.match(config.apiKey, /^rr-/); + assert.equal(JSON.parse(fs.readFileSync(filePath, "utf8")).apiKey, config.apiKey); + }); + + it("preserves invalid JSON and creates an exact recovery copy", () => { + const filePath = tempConfig(); + const original = '{"apiKey":"rr-do-not-destroy"'; + fs.writeFileSync(filePath, original, { mode: 0o600 }); + + assert.throws( + () => createStore(filePath).load(), + (error) => { + assert.ok(error instanceof ConfigLoadError); + assert.equal(error.code, "CONFIG_LOAD_FAILED"); + assert.equal(error.filePath, filePath); + assert.ok(error.recoveryPath); + assert.match(error.message, /not valid JSON/); + assert.equal(fs.readFileSync(error.recoveryPath, "utf8"), original); + assert.equal(fs.statSync(error.recoveryPath).mode & 0o777, 0o600); + return true; + } + ); + assert.equal(fs.readFileSync(filePath, "utf8"), original); + }); + + it("does not replace a structurally invalid JSON root", () => { + const filePath = tempConfig(); + const original = "[]"; + fs.writeFileSync(filePath, original, { mode: 0o600 }); + + assert.throws(() => createStore(filePath).load(), /JSON root must be an object/); + assert.equal(fs.readFileSync(filePath, "utf8"), original); + const recoveryFiles = fs + .readdirSync(path.dirname(filePath)) + .filter((name) => name.startsWith("config.json.recovery-")); + assert.equal(recoveryFiles.length, 1); + assert.equal(fs.readFileSync(path.join(path.dirname(filePath), recoveryFiles[0]), "utf8"), original); + }); + + it("surfaces non-missing read failures without replacing the existing path", () => { + const filePath = tempConfig(); + fs.mkdirSync(filePath); + + assert.throws( + () => createStore(filePath).load(), + (error) => { + assert.ok(error instanceof ConfigLoadError); + assert.equal(error.code, "CONFIG_LOAD_FAILED"); + assert.equal(error.recoveryPath, null); + assert.match(error.message, /left untouched/); + return true; + } + ); + assert.equal(fs.statSync(filePath).isDirectory(), true); + }); +}); diff --git a/tests/usage.test.js b/tests/usage.test.js new file mode 100644 index 0000000..2eca318 --- /dev/null +++ b/tests/usage.test.js @@ -0,0 +1,122 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { describe, it } = require("node:test"); + +const { createUsageStore } = require("../src/lib/usage"); + +function withTempUsage(run) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "rerouted-usage-")); + try { + run(directory); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +describe("SQLite usage history", () => { + it("retains more than the former 20,000-event limit", () => { + withTempUsage((directory) => { + const store = createUsageStore(path.join(directory, "usage.sqlite")); + const eventCount = 20_125; + try { + for (let index = 0; index < eventCount; index++) { + store.record({ + model: `model-${index % 3}`, + providerType: "xai", + providerName: "xAI (Grok)", + status: index % 10 === 0 ? 429 : 200, + prompt_tokens: 2, + completion_tokens: 1, + total_tokens: 3, + }); + } + + assert.equal(store.totalsAllTime().allTimeRequests, eventCount); + assert.equal(store.aggregate("all").requests, eventCount); + assert.equal(store.aggregate("all").total_tokens, eventCount * 3); + assert.equal(store.recent(eventCount).length, eventCount); + } finally { + store.close(); + } + }); + }); + + it("migrates every legacy JSON row exactly once without deleting the source", () => { + withTempUsage((directory) => { + const databasePath = path.join(directory, "usage.sqlite"); + const legacyPath = path.join(directory, "usage.json"); + const eventCount = 20_075; + const now = Date.now(); + const events = Array.from({ length: eventCount }, (_, index) => ({ + at: now - index, + model: `legacy-${index % 5}`, + upstream: "legacy-upstream", + providerId: "prov_legacy", + providerType: "claude", + providerName: "Claude", + accountAlias: "oauth1", + status: index % 7 === 0 ? 500 : 200, + stream: index % 2 === 0, + prompt_tokens: index % 11, + completion_tokens: index % 13, + cached_tokens: index % 3, + total_tokens: (index % 11) + (index % 13), + error: index % 7 === 0 ? "legacy failure" : null, + legacyMarker: `row-${index}`, + })); + fs.writeFileSync(legacyPath, JSON.stringify({ version: 1, events })); + + let store = createUsageStore(databasePath, { legacyPath }); + try { + assert.equal(store.totalsAllTime().allTimeRequests, eventCount); + assert.equal(store.aggregate("all").requests, eventCount); + assert.deepEqual(store.recent(1)[0], events[0]); + assert.equal(fs.existsSync(legacyPath), true); + } finally { + store.close(); + } + + store = createUsageStore(databasePath, { legacyPath }); + try { + assert.equal(store.totalsAllTime().allTimeRequests, eventCount); + assert.deepEqual(store.recent(1)[0], events[0]); + } finally { + store.close(); + } + }); + }); + + it("preserves a corrupt database and starts a fresh usable history", () => { + withTempUsage((directory) => { + const databasePath = path.join(directory, "usage.sqlite"); + const corruptBytes = Buffer.from("this is not a sqlite database\n", "utf8"); + fs.writeFileSync(databasePath, corruptBytes); + const originalConsoleError = console.error; + const warnings = []; + console.error = (...parts) => warnings.push(parts.join(" ")); + + let store; + try { + store = createUsageStore(databasePath); + } finally { + console.error = originalConsoleError; + } + + try { + assert.ok(store.recovery); + assert.match(store.recovery.reason, /file is not a database/i); + assert.deepEqual(fs.readFileSync(store.recovery.recoveryPath), corruptBytes); + assert.match(warnings.join("\n"), /preserved.*fresh usage database/i); + assert.equal(store.totalsAllTime().allTimeRequests, 0); + store.record({ model: "after-recovery", status: 200, total_tokens: 1 }); + assert.equal(store.totalsAllTime().allTimeRequests, 1); + } finally { + store.close(); + } + }); + }); +});