diff --git a/.env.mac.example b/.env.mac.example index d21b8302..95cd939c 100644 --- a/.env.mac.example +++ b/.env.mac.example @@ -13,6 +13,13 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Daemon URL — daemon runs natively on the macOS host, containers reach it via host.docker.internal DAEMON_URL=http://host.docker.internal:3847 +# --- Control-plane token --- +# Shared secret between the daemon control plane and its callers (dashboard, +# briefing-summarizer, concierge, CLI). The native install script generates and +# appends this to .env.mac automatically if missing. Rotate by generating a new +# value, updating .env.mac, and reinstalling/reloading the daemon and dashboard. +RUNFORGE_CONTROL_TOKEN= + # --- Self-hosted Postgres --- POSTGRES_DB=runforge POSTGRES_USER=runforge diff --git a/.env.prod.example b/.env.prod.example index 09051d98..d4db77f9 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -11,6 +11,12 @@ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # If using Claude Max subscription locally (not in Docker), this is not needed ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# --- Control-plane token --- +# Shared secret between the daemon control plane and its callers (dashboard, +# briefing-summarizer, concierge, CLI). Required in Docker because the daemon +# binds on 0.0.0.0. The compose file fails fast if this is unset. +RUNFORGE_CONTROL_TOKEN= + # Self-hosted Postgres used by app-owned stores POSTGRES_DB=runforge POSTGRES_USER=runforge diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fb08266..cf18f6e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,8 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: pnpm/action-setup@v4 @@ -63,6 +65,33 @@ jobs: - run: pnpm install --frozen-lockfile + - name: Audit (prod, high) + run: pnpm audit --prod --audit-level high + + - name: Gitleaks (full history) + run: | + set -u + VERSION=8.30.1 + ARCH="$(uname -m)" + case "$ARCH" in + arm64) + FILE="gitleaks_${VERSION}_darwin_arm64.tar.gz" + CHECKSUM="b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5" + ;; + x86_64) + FILE="gitleaks_${VERSION}_darwin_x64.tar.gz" + CHECKSUM="dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709" + ;; + *) + echo "::error::Unsupported architecture: $ARCH" + exit 1 + ;; + esac + curl -sL -o "/tmp/$FILE" "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/$FILE" + echo "$CHECKSUM /tmp/$FILE" | shasum -a 256 -c + tar -xzf "/tmp/$FILE" -C /tmp + /tmp/gitleaks detect --redact + # Real Postgres for the decision-index integration suite. The runner is # self-hosted (macOS), where GitHub `services:` containers are Linux-only, # so start Postgres via `docker run` on a random host port (avoids clashing diff --git a/.specify/architecture/operator-auth.md b/.specify/architecture/operator-auth.md index e7d6146d..532a3224 100644 --- a/.specify/architecture/operator-auth.md +++ b/.specify/architecture/operator-auth.md @@ -40,7 +40,7 @@ These records physically reside in the shared operational data store, but their - The **Auth Service** owns identity, sessions, role assignment, invitations, bootstrap state, and every authorization decision. - The **Dashboard** enforces the session-and-role gate on the server side before any privileged view or change; it never trusts a role asserted by the client. -- **Agent Service and daemon control operations** are protected by the same administrator-only gate. +- The **Agent Service and daemon control operations** are protected by the same administrator-only gate; the daemon independently enforces a bearer-token boundary on its control-plane routes. - The **Data Service** provides only the shared store instance and the Migration Runner that physically create authorization records. No authorization logic lives in the data store; the data store's own policy engine is not used for access control. - **Coexistence during the staged transition:** the existing Dashboard architecture remains authoritative for current sign-in behavior until the project-owned replacement lands. This architecture defines the target. Governed paths and the deprecation of superseded stack specifications transfer only in later implementation work, recorded via metadata, never by deletion. diff --git a/.specify/functional/operator-auth.md b/.specify/functional/operator-auth.md index 12e6f886..a290f1ab 100644 --- a/.specify/functional/operator-auth.md +++ b/.specify/functional/operator-auth.md @@ -67,6 +67,7 @@ Runforge's dashboard relies on an external hosted provider to sign operators in - The administrator-versus-viewer capability distinction is enforced before any privileged view or change, is decided by the application's own rules rather than by where data is stored, and defaults to refusal. - The all-or-nothing sign-in bypass is replaced by an explicit, named, local-only convenience mode that cannot activate in a production environment. - Every operator and role present before the move has documented, verified equivalent access afterward, and no operator capability is weakened relative to the prior system. +- The daemon's control-plane routes require `Authorization: Bearer ` for every route except `GET /health`, and refuse to start on a non-loopback bind without a token. ## Constraints diff --git a/.specify/stack/operator-auth-ts.md b/.specify/stack/operator-auth-ts.md index cd9d8407..0aabcaf2 100644 --- a/.specify/stack/operator-auth-ts.md +++ b/.specify/stack/operator-auth-ts.md @@ -29,8 +29,9 @@ Chosen over a hand-rolled session system (security risk, the issue forbids weake ## Key Decisions - **Better Auth + Drizzle adapter** — auth tables defined in the shared `packages/db` schema but **created by the Data Platform Migration Runner** (STACK-AC-DATA-PLATFORM owns ordering; this spec owns their definition and semantics). -- **Server-side enforcement only** — a `requireSession()` in route handlers and server components; the client-asserted role is never trusted. Daemon control routes sit behind an admin-only variant. -- **Pure `gateDecision` predicate** — `(session, { localBypass }) → '/login' | null | 'deny'`; no I/O, fully unit-tested, mirrors the regulated pilot deployment's `gate-decision.ts` split. + - **Server-side enforcement only** — a `requireSession()` in route handlers and server components; the client-asserted role is never trusted. Dashboard enforces roles; the daemon enforces the bearer boundary on the control plane with `RUNFORGE_CONTROL_TOKEN`. + - **Daemon control-plane auth model** — the daemon guards every control-plane route except `GET /health` with `Authorization: Bearer `; the bind host must be IPv4 loopback (`127.0.0.0/8`) when no token is configured, otherwise non-loopback binds are refused at startup. A legacy loopback mode allows tokenless operation on loopback with loud deprecation warnings. `X-Requested-By` is kept as CSRF/provenance defense on mutating methods, running after the bearer check. The built-in HTML dashboard is legacy/loopback-only. + - **Pure `gateDecision` predicate** — `(session, { localBypass }) → '/login' | null | 'deny'`; no I/O, fully unit-tested, mirrors the regulated pilot deployment's `gate-decision.ts` split. - **App-owned `role`** — `administrator | viewer` on the membership/user record, enforced in application code; Supabase `is_admin()`/`is_member()` SQL and all RLS policies are removed. - **`AUTH_DISABLED` → `LOCAL_AUTH_BYPASS`** — the blunt switch is replaced by a named local-only bypass that activates only when an explicit local flag is set **and** no production indicator (`NODE_ENV=production`, deploy markers) is present; it refuses in production and logs the refusal. - **Continuity** — preserve first-user-is-admin bootstrap and the invitation flow; `team_members` / `invitations` semantics carry over with documented operator migration. diff --git a/.specify/traceability.yml b/.specify/traceability.yml index 347e2256..42166fa2 100644 --- a/.specify/traceability.yml +++ b/.specify/traceability.yml @@ -369,6 +369,7 @@ STACK-AC-OPERATOR-SURFACE-CLIENT: - packages/dashboard/app/api/decisions/answer/route.test.ts - packages/dashboard/app/api/daemon/daemon-routes.test.ts - packages/dashboard/app/api/daemon/p3-halt-proxy.gate.test.ts + - packages/dashboard/app/api/metrics/escalation/route.test.ts - packages/dashboard/components/steering/p3-live-inbox.gate.test.tsx - packages/dashboard/components/steering/p3-stale-stub-cleanup.gate.test.ts - packages/dashboard/app/api/decisions/[id]/route.test.ts @@ -1709,10 +1710,23 @@ STACK-AC-OPERATOR-AUTH: - packages/dashboard/lib/auth/** - packages/dashboard/app/api/auth/** - packages/dashboard/app/(auth)/** + - packages/daemon/src/control-plane/server.ts + - packages/daemon/src/control-plane/degraded-server.ts + - packages/daemon/src/control-plane/control-auth.ts + - packages/daemon/src/control-plane/resolve-control-token.ts + - packages/daemon/src/control-plane/cli.ts + - packages/daemon/src/main.ts + - packages/dashboard/lib/daemon-fetch.ts + - docker-compose.yml + - scripts/install-daemon.sh + - scripts/com.runforge.daemon.plist test_paths: - packages/auth/**/*.test.ts - packages/dashboard/lib/auth/**/*.test.ts - packages/dashboard/app/api/auth/**/*.test.ts + - packages/daemon/src/control-plane/server.test.ts + - packages/daemon/src/control-plane/control-auth.test.ts + - packages/daemon/src/control-plane/resolve-control-token.test.ts status: draft # Operator-approved production release (D5). FUNC status: draft pending Operator approval diff --git a/docker-compose.yml b/docker-compose.yml index 02f3b319..9e2769c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,6 +67,7 @@ services: NODE_ENV: production PLUGINS_DIR: /app/plugins ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY in the selected env file} + RUNFORGE_CONTROL_TOKEN: ${RUNFORGE_CONTROL_TOKEN:?set RUNFORGE_CONTROL_TOKEN in the selected env file} ports: - "${DASHBOARD_PORT:-127.0.0.1:3000:3000}" depends_on: @@ -93,6 +94,7 @@ services: DAEMON_HOST: "0.0.0.0" DAEMON_DATA_BACKEND: ${DAEMON_DATA_BACKEND:-postgres} ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY in the selected env file} + RUNFORGE_CONTROL_TOKEN: ${RUNFORGE_CONTROL_TOKEN:?set RUNFORGE_CONTROL_TOKEN in the selected env file} # The container IS the externally-managed sandbox, so workers run with # --dangerously-skip-permissions to clear the CLI "Workspace not trusted" # gate on dynamic worktree cwds. The daemon's PreToolUse containment hooks @@ -142,6 +144,7 @@ services: BRIEFING_DATA_BACKEND: ${BRIEFING_DATA_BACKEND:-postgres} NODE_ENV: production GIT_REPO_PATH: /repo + RUNFORGE_CONTROL_TOKEN: ${RUNFORGE_CONTROL_TOKEN:?set RUNFORGE_CONTROL_TOKEN in the selected env file} depends_on: migrate: condition: service_completed_successfully diff --git a/docs/running.md b/docs/running.md index 41c2b8ed..4ec15459 100644 --- a/docs/running.md +++ b/docs/running.md @@ -31,6 +31,7 @@ cp .env.prod.example .env.prod |----------|----------|-------------| | `GITHUB_TOKEN` | Yes | GitHub PAT with `repo` scope | | `ANTHROPIC_API_KEY` | Yes | Anthropic API key | +| `RUNFORGE_CONTROL_TOKEN` | Yes | Shared secret for daemon control-plane bearer auth | | `POSTGRES_DB` | Yes | Compose-managed Postgres database name | | `POSTGRES_USER` | Yes | Compose-managed Postgres user | | `POSTGRES_PASSWORD` | Yes | Compose-managed Postgres password | @@ -119,11 +120,18 @@ The macOS host uses a **hybrid deployment**: dashboard and briefing-summarizer r # 1. Start the daemon natively (if not already running via launchd) cd packages/daemon && pnpm start & -# 2. Start Postgres, migrations, dashboard, and briefing-summarizer in Docker +# 2. Start Postgres, migrations, dashboard, and briefing-summarizer in Docker. +# Both ENV_FILE and --env-file are required: ENV_FILE feeds each service's +# env_file:, --env-file feeds the ${...:?} interpolation in the compose file. ENV_FILE=.env.mac docker compose --env-file .env.mac up --build -d ``` -Dashboard is available at `http://localhost:3000` on the local network. Auth is disabled. The dashboard connects to the native daemon via `host.docker.internal:3847`. +Dashboard is available at `http://localhost:3000` on the local network. Auth is disabled. The dashboard connects to the native daemon via `host.docker.internal:3847` and forwards `RUNFORGE_CONTROL_TOKEN` on every control-plane request. + +> **Token rotation:** update `RUNFORGE_CONTROL_TOKEN` in `.env.mac`, run +> `ENV_FILE=.env.mac docker compose --env-file .env.mac up -d` to recreate the +> containers, and reinstall/reload the native daemon plist so the new token is +> in the daemon's environment. ## Database Migrations @@ -284,11 +292,15 @@ Use `/pause` when you want the daemon to stop picking up work but let current ru # Emergency halt (requires X-Requested-By; Bearer token if RUNFORGE_CONTROL_TOKEN is set) curl -fsS -X POST localhost:3847/halt \ -H 'X-Requested-By: operator' \ - -H 'Authorization: Bearer ' + -H "Authorization: Bearer ${RUNFORGE_CONTROL_TOKEN}" # Pause / resume -curl -fsS -X POST localhost:3847/pause -H 'X-Requested-By: operator' -curl -fsS -X POST localhost:3847/resume -H 'X-Requested-By: operator' +curl -fsS -X POST localhost:3847/pause \ + -H 'X-Requested-By: operator' \ + -H "Authorization: Bearer ${RUNFORGE_CONTROL_TOKEN}" +curl -fsS -X POST localhost:3847/resume \ + -H 'X-Requested-By: operator' \ + -H "Authorization: Bearer ${RUNFORGE_CONTROL_TOKEN}" ``` A paused daemon also gates integrate entry: a run that reaches the `integrate` phase while paused is parked at `pausedAtPhase: 'integrate'` instead of merging, then resumes through the normal integrate arm after `/resume`. diff --git a/docs/security-overrides.md b/docs/security-overrides.md new file mode 100644 index 00000000..ad25888e --- /dev/null +++ b/docs/security-overrides.md @@ -0,0 +1,20 @@ +# Security Overrides + +`pnpm.overrides` pins in root `package.json` for high-severity transitive vulnerabilities +that are not cleared by direct dependency upgrades alone. + +| Package path | Pinned version | Advisory(s) | Date | +|---|---|---|---| +| `micromatch>picomatch` | 2.3.2 | GHSA-c2c7-rcm5-vvqj | 2026-07-07 | +| `path-to-regexp` | 8.4.2 | GHSA-j3q9-mxjg-w52f | 2026-07-07 | +| `fast-uri` | 3.1.3 | GHSA-q3j6-qgpj-74h6, GHSA-v39h-62p7-jpjc | 2026-07-07 | +| `vitest>picomatch` | 4.0.5 | GHSA-c2c7-rcm5-vvqj | 2026-07-07 | +| `vitest>vite` | 7.3.6 | GHSA-v2wj-q39q-566r, GHSA-p9ff-h696-f583, GHSA-fx2h-pf6j-xcff | 2026-07-07 | +| `jsdom>undici` | 7.28.0 | GHSA-vmh5-mc38-953g, GHSA-vxpw-j846-p89q, GHSA-hm92-r4w5-c3mj | 2026-07-07 | +| `@dotenvx/dotenvx>picomatch` | 4.0.5 | GHSA-c2c7-rcm5-vvqj | 2026-07-07 | + +Direct upgrades performed alongside these overrides: + +- `next` 16.2.0 → 16.2.10 (dashboard) +- `hono` ^4.12.8 → ^4.12.28 (concierge) +- `better-auth` 1.6.11 → 1.6.23 (dashboard; moves `vitest` from a regular dependency to a peer dependency, eliminating most dev-graph audit noise without overrides) diff --git a/docs/superpowers/handoffs/control-plane-hardening.findings.md b/docs/superpowers/handoffs/control-plane-hardening.findings.md new file mode 100644 index 00000000..eb548575 --- /dev/null +++ b/docs/superpowers/handoffs/control-plane-hardening.findings.md @@ -0,0 +1,46 @@ +# Control-Plane Hardening — Implementation Deep-Review Findings + +Review of `git diff origin/main...HEAD` (branch `codex/control-plane-hardening-build`, PR #1). +Lanes: Claude adversarial review + codex `exec review --base main` (gpt-5.5, high effort). Merged and deduped; every codex finding independently verified against code + design spec. + +**Verdict: NOT-CLEAN** — 0 critical, 3 important-on-new-code. + +## Gate results (all green) + +- `pnpm --filter @runforge/daemon exec vitest run src/control-plane/__acceptance__` — 2 files, 12 tests passed +- `pnpm check:traceability` — 738 path entries clean +- `node scripts/check-ci-workflows.mjs` — 1 workflow clean +- Extra: daemon control-plane unit suites (115 tests) and dashboard daemon-fetch/daemon-route suites (77 tests) passed + +## IMPORTANT (on new code) + +### I1 — `sync-claude-creds.sh` breaks first-time sync when `CREDS_DIR` does not exist yet +`scripts/sync-claude-creds.sh:52-53` — the new validation-log `mktemp "${CREDS_DIR}/.sync-claude-creds.validate.XXXXXX"` runs **before** the `mkdir -p "${CREDS_DIR}"` at step 3 (line ~71). On a fresh install where the bind-mount creds dir does not exist, `mktemp` fails and `set -euo pipefail` aborts the script before `.credentials.json` is ever written. This is a regression introduced by moving the log out of `/tmp` (the old code worked because nothing touched `CREDS_DIR` before the `mkdir -p`). Fix: `mkdir -p "${CREDS_DIR}"` before the `mktemp`. *(codex, verified)* + +### I2 — CLI `.env.mac` token fallback resolves from `cwd`, not the repo root (spec deviation) +`packages/daemon/src/main.ts:133-135` and the duplicate in `packages/daemon/src/control-plane/cli.ts:64-66` — `resolveControlToken()` reads `resolve(process.cwd(), '.env.mac')`. The design spec (2026-07-07-control-plane-hardening-design.md, "daemon CLI" bullet) requires the **repo-root** `.env.mac`. Running the CLI from any subdirectory (e.g. `pnpm --filter @runforge/daemon ...` sets cwd to `packages/daemon`) silently finds no token, so `status`/`pause`/`resume` fail with 401 against a token-protected daemon. Fix: resolve from repo root (e.g. walk up to a `.git`/`pnpm-workspace.yaml` marker or use module location). *(codex, verified)* + +### I3 — `metrics/escalation` and `decisions/pending` proxies swallow `DaemonAuthError` silently (spec deviation) +`packages/dashboard/app/api/metrics/escalation/route.ts:43-46` and `packages/dashboard/app/api/decisions/pending/route.ts:51-56` — both catch `DaemonAuthError` but return the exact same degraded 200 payload as the generic catch, with no log and no actionable message (the added `if` branch is a no-op). The design spec requires **every** `daemonFetch` caller to return a 500-family JSON with the actionable RUNFORGE_CONTROL_TOKEN message so auth failures don't collapse into "unavailable data". Token misconfiguration on these two routes is invisible to the operator. Fix: return a 500 with `e.message` (or at minimum `console.error` it) for the `DaemonAuthError` branch. *(codex, verified)* + +## MINOR + +- **`checkAuthorization` scheme match is now case-sensitive** — `packages/daemon/src/control-plane/control-auth.ts:44` requires exactly `Bearer`; the replaced `/halt` code accepted `bearer` (`parts[0]?.toLowerCase()`). RFC 7235 auth schemes are case-insensitive; a lowercase-scheme client that worked before now gets 403. All in-repo callers send `Bearer`, so impact is external-client only. +- **`install-daemon.sh` provisioning trusts inherited shell env** — `scripts/install-daemon.sh:16-31` — `provision_control_token` sources `$ENV_FILE` and skips appending when `RUNFORGE_CONTROL_TOKEN` is already non-empty; a token exported in the operator's shell (but absent from `.env.mac`) means the plist gets the shell token while compose consumers of `.env.mac` get nothing → daemon/dashboard token mismatch. Also `chmod 600 "$ENV_FILE"` is applied only on the generate path, not when a token already exists. +- **Duplicated `resolveControlToken`** in `packages/daemon/src/main.ts` and `packages/daemon/src/control-plane/cli.ts` (design explicitly allowed updating both, so tracked as a consolidation cleanup only; fix together with I2). +- **`packages/concierge/src/tools/ac.ts:56-63`** — `init.headers` is cast to `Record`; a `Headers` instance or entries-array would be silently mangled. All current in-file callers pass plain objects. +- **Env-var mutation in test files** (`process.env.RUNFORGE_CONTROL_TOKEN` set/deleted across several daemon test files) relies on vitest worker isolation; a future pool-config change could introduce cross-file flakiness. Save/restore hygiene is present everywhere. + +## Pre-existing (not introduced by this diff) + +- `daemonFetch` spreads `options?.headers` (`packages/dashboard/lib/daemon-fetch.ts:24`) — a `Headers` instance would spread to nothing. Pre-existing pattern; all callers pass plain objects. The new code correctly spreads the `Authorization` override *after* caller headers, so callers cannot override it (tested). + +## Verified-correct highlights (no findings) + +- Timing-safe compare with length pre-check and try/catch; 401 (missing header) vs 403 (bad token) mapping matches spec. +- `/health` exempt on both the control server and the degraded server; bearer check runs **before** the X-Requested-By CSRF check; `/halt` folded into the global boundary with identical legacy semantics. +- `assertBindAllowed` at all three listen paths (`server.ts:106`, `degraded-server.ts:32`, `daemon.ts:617` startup gate) — tokenless non-loopback refuses before `listen`, with tests asserting `listen` is never called. +- Legacy loopback mode: startup warning in `daemon.ts` + rate-limited (60 s) per-request warning in `server.ts`. +- All three concierge call sites, briefing-summarizer, dashboard `page.tsx`, and both CLI `callApi`s attach the bearer; `DaemonAuthError` is mapped in every mutating proxy route (except the two GET proxies in I3). +- Compose `${RUNFORGE_CONTROL_TOKEN:?}` on all three daemon-calling services; plist placeholder + installer substitution; `pnpm.overrides` in root `package.json` exactly match `docs/security-overrides.md`. +- CI: `fetch-depth: 0`, pinned gitleaks 8.30.1 with sha256 verification, audit + gitleaks as steps **inside** the `ci` job; `check-ci-workflows.mjs` passes (no services/container violations). diff --git a/docs/superpowers/handoffs/control-plane-hardening.result.md b/docs/superpowers/handoffs/control-plane-hardening.result.md new file mode 100644 index 00000000..fa943400 --- /dev/null +++ b/docs/superpowers/handoffs/control-plane-hardening.result.md @@ -0,0 +1,31 @@ +--- +status: complete +pr: https://github.com/DanielSoCra/runforge/pull/1 +branch: codex/control-plane-hardening-build +session: not available +verify_command_result: pass +--- + +## Done + +- Implemented daemon control-plane bearer auth boundary (`control-auth.ts`, `server.ts`, `degraded-server.ts`, `daemon.ts` startup gate) with loopback legacy mode and `/health` exemption. +- Updated all clients to forward `RUNFORGE_CONTROL_TOKEN`: dashboard `daemonFetch` + proxy routes, briefing-summarizer, concierge, daemon CLI (`main.ts`/`cli.ts`). +- Added typed `DaemonAuthError` in dashboard and handled it in every `daemonFetch` caller. +- Completed deployment plumbing: compose token requirement, launchd installer token provisioning, plist placeholder, creds validation log permissions fix. +- Cleared high-severity prod audit findings via direct upgrades (`next` 16.2.10, `hono` 4.12.28, `better-auth` 1.6.23) and root `pnpm.overrides` pins for transitive packages. +- Added security steps inside the required `ci` job: `pnpm audit --prod --audit-level high` and full-history `gitleaks detect --redact` with pinned binary + sha256 verification. +- Updated `.specify` specs/traceability and created `docs/security-overrides.md`. +- Full gates green: `pnpm typecheck && pnpm lint && pnpm test && pnpm build`, `pnpm check:traceability && pnpm check:workflows`, `gitleaks detect --redact`, `pnpm audit --prod --audit-level high`. +- Acceptance tests pass via `pnpm --filter @runforge/daemon exec vitest run src/control-plane/__acceptance__`. +- Committed, pushed `codex/control-plane-hardening-build`, and opened PR #1 against `main`. + +## Unverified / risks + +- CI `Gitleaks (full history)` step will only prove itself on the actual GitHub Actions run; the local `gitleaks detect --redact` is clean. +- `better-auth` 1.6.23 moved `vitest` to a peer dependency, but `pnpm audit --prod` still reports its transitive dev-graph packages unless overridden; the overrides are in place and audit exits 0. +- Dependency override pins (`pnpm.overrides`) can drift over time; recorded in `docs/security-overrides.md` with advisory IDs and date. + +## Dead ends + +- Tried nested path overrides (`better-auth>vitest>picomatch`) — pnpm rejects selectors deeper than one parent-child level; switched to one-level overrides (`vitest>picomatch`, `jsdom>undici`, etc.) which pnpm accepts. +- Initially attempted to clear better-auth audit noise by upgrading alone; the package still resolves its peer dev graph into the audit report, so overrides were required for those transitive packages. diff --git a/docs/superpowers/handoffs/control-plane-hardening.work-order.md b/docs/superpowers/handoffs/control-plane-hardening.work-order.md new file mode 100644 index 00000000..8c1ea769 --- /dev/null +++ b/docs/superpowers/handoffs/control-plane-hardening.work-order.md @@ -0,0 +1,123 @@ +--- +topic: control-plane-hardening +plan: docs/superpowers/plans/2026-07-07-control-plane-hardening.md +spec: docs/superpowers/specs/2026-07-07-control-plane-hardening-design.md +acceptance_tests: + - packages/daemon/src/control-plane/__acceptance__/control-auth.acceptance.test.ts + - packages/daemon/src/control-plane/__acceptance__/server-auth.acceptance.test.ts +verify_command: "pnpm --filter @runforge/daemon exec vitest run src/control-plane/__acceptance__" +branch: codex/control-plane-hardening-build +base_branch: worktree-review-findings-hardening +result_path: docs/superpowers/handoffs/control-plane-hardening.result.md +findings_path: docs/superpowers/handoffs/control-plane-hardening.findings.md +do_not_modify: + - packages/daemon/src/control-plane/__acceptance__/control-auth.acceptance.test.ts + - packages/daemon/src/control-plane/__acceptance__/server-auth.acceptance.test.ts + - docs/superpowers/specs/** + - docs/superpowers/plans/** +conventions: AGENTS.md +--- + +## Task + +Implement the control-plane hardening exactly per the spec (`docs/superpowers/specs/2026-07-07-control-plane-hardening-design.md` — authoritative on behavior) and plan. The full task list, inline. Dependency order: Task 1 independent; Task 2 → {3,4,5}; Tasks 6,7 after 3; Task 8 after 5+6+7; Tasks 9,10 independent; Task 11 last. Keep every commit boundary green (`pnpm check:traceability`, daemon tests). + +NOTE — dot-directory files you must open by EXPLICIT path (globbing skips them): +`.specify/stack/operator-auth-ts.md`, `.specify/architecture/operator-auth.md`, `.specify/functional/operator-auth.md`, `.specify/traceability.yml`, `.github/workflows/ci.yml`, `.env.mac.example`, `.env.prod.example`, `.gitleaks.toml`. + +### Task 0 — Baseline +`pnpm install --frozen-lockfile`, then `pnpm --filter @runforge/daemon run test` must pass (the `__acceptance__` tests are EXPECTED to fail until you implement — run the rest of the suite to confirm baseline). Do NOT modify the acceptance tests, ever. + +### Task 1 — .specify updates +Files: `.specify/stack/operator-auth-ts.md` (STACK-AC-OPERATOR-AUTH), `.specify/architecture/operator-auth.md`, `.specify/functional/operator-auth.md`, `.specify/traceability.yml`. +- Extend STACK-AC-OPERATOR-AUTH with the daemon control-plane auth model: bearer `RUNFORGE_CONTROL_TOKEN` on every route except `GET /health` (both servers); bind-host startup gate (IPv4-only contract, loopback = 127.0.0.0/8; non-loopback + no token = refuse to start); legacy loopback mode (loopback + no token = start with loud warnings, `/halt` token-optional); `X-Requested-By` demoted to CSRF/provenance defense on mutating methods; built-in HTML dashboard = legacy/loopback-only. +- Update ARCH/FUNC parents only where they describe the boundary ("role enforcement happens in the dashboard" → "dashboard enforces roles; daemon enforces the bearer boundary"). +- traceability.yml: under the operator-auth stack spec add code_paths that exist now: `packages/daemon/src/control-plane/server.ts`, `packages/daemon/src/control-plane/degraded-server.ts`, `packages/dashboard/lib/daemon-fetch.ts`, `docker-compose.yml`, `scripts/install-daemon.sh`, `scripts/com.runforge.daemon.plist`; test_paths: `packages/daemon/src/control-plane/server.test.ts`. (`control-auth.*` entries land in Task 2's commit.) +- Verify `pnpm check:traceability` exits 0. +Commit: `spec(operator-auth): daemon control-plane bearer boundary + traceability` + +### Task 2 — control-auth.ts (TDD) +New: `packages/daemon/src/control-plane/control-auth.ts` + `control-auth.test.ts`. API (must satisfy the acceptance tests exactly): +```ts +export class ControlBindError extends Error {} +export function isLoopbackHost(host: string): boolean // IPv4 127.0.0.0/8 only +export function assertBindAllowed(host: string, token: string | undefined): void +export type AuthResult = { ok: true } | { ok: false; status: 401 | 403; error: string } +export function checkAuthorization(authorizationHeader: string | string[] | undefined, token: string): AuthResult +``` +- non-loopback + missing/empty token → ControlBindError with actionable message; loopback + no token → ok (caller warns). +- checkAuthorization: missing header → 401; non-Bearer scheme or wrong value → 403; compare Buffer byte lengths FIRST, then crypto.timingSafeEqual (throws on length mismatch otherwise). +- Own unit tests in control-auth.test.ts (matrix per plan Task 2) + add both new files to `.specify/traceability.yml` in this commit; `pnpm check:traceability` green. +Commit: `feat(daemon): control-plane auth primitives (bind gate + bearer check)` + +### Task 3 — server.ts enforcement +File: `packages/daemon/src/control-plane/server.ts`. +- Read `RUNFORGE_CONTROL_TOKEN` per request (mirror how /halt reads it today ~line 152, so tests can toggle env). +- BEFORE route dispatch and body reads: token configured → `checkAuthorization(req.headers.authorization, token)`; failure → 401/403 JSON `{error}` for every path except `GET /health`. Token not configured → allow, log rate-limited deprecation warning (≤1/minute). +- Keep the X-Requested-By presence check for POST/PUT, running AFTER the bearer check. +- /halt: remove its ad-hoc token block (globally covered now); with no token configured it stays reachable with only the CSRF header. +- `createControlServer(port, handlers, host)`: call `assertBindAllowed(host, process.env.RUNFORGE_CONTROL_TOKEN)` before listen. +- Update `server.test.ts`: matrix {token set, unset} × {mutating POST, sensitive GET (/status, /decisions/pending), GET /health, /halt} → {2xx/expected, 401, 403}; save/restore RUNFORGE_CONTROL_TOKEN per test (pattern: `phase0-halt.gate.test.ts` lines ~15, 102-104); REWRITE the test at server.test.ts:499-504 (currently asserts tokenless '0.0.0.0' succeeds) to set a token, and add a fail-closed assertion (ControlBindError) for tokenless non-loopback. +Commit: `feat(daemon): require bearer on control plane; fail closed off-loopback` + +### Task 4 — degraded-server.ts +Same rule via checkAuthorization: `/status` requires bearer when token set; `GET /health` always open; `assertBindAllowed` before listen. Tests in a degraded-server test file. +Commit: `feat(daemon): degraded server honors control token` + +### Task 5 — daemon startup gate +File: `packages/daemon/src/control-plane/daemon.ts` (host resolution ~604-612, degraded server start ~672-678). +- After resolving daemonHost: `assertBindAllowed(daemonHost, process.env.RUNFORGE_CONTROL_TOKEN)`; ControlBindError = fatal startup error with actionable message. Loopback + no token → log legacy-mode startup warning once. +- Daemon-level test: non-loopback + no token refuses startup; loopback + no token starts with warning. +Commit: `feat(daemon): refuse non-loopback bind without control token` + +### Task 6 — dashboard client + proxy sweep +Files: `packages/dashboard/lib/daemon-fetch.ts`, `packages/dashboard/app/(dashboard)/page.tsx`, `packages/dashboard/app/api/daemon/halt/route.ts`, all daemonFetch callers. +- daemonFetch: when env token set, set `Authorization: Bearer ` AFTER merging caller headers (not overridable). On daemon 401/403 throw typed `DaemonAuthError` (export beside DaemonConfigError; message: control token missing or invalid — set RUNFORGE_CONTROL_TOKEN in the dashboard environment). +- page.tsx (~line 24): replace direct `fetch(${DAEMON_URL}/status)` with daemonFetch('/status', …), keep error handling. +- halt/route.ts: delete ad-hoc bearer injection (~lines 31-38). +- Sweep EVERY daemonFetch caller (grep is authoritative). API routes → catch DaemonAuthError, return 500-family JSON with the actionable message. Known floor: `app/api/daemon/{status,pause,resume,halt,release,issues/scan,remote-control/restart,repos-reload}/route.ts`, `app/api/decisions/pending/route.ts`, `app/api/decisions/[id]/route.ts`, `app/api/decisions/answer/route.ts`, `app/api/decisions/[id]/reveal/route.ts`, `app/api/metrics/escalation/route.ts`. Non-route callers — server actions (`actions/repos.ts` ~40, `actions/github-connections.ts` ~9) and server components (`app/(dashboard)/metrics/page.tsx` ~22, `app/(dashboard)/steering/page.tsx` ~37, `app/(dashboard)/page.tsx`): treat DaemonAuthError like their existing DaemonConfigError/unreachable handling (degrade to offline/error state) but include the auth message. +- Tests: daemon-fetch unit tests (bearer on GET+POST when set; absent when unset; not overridable; 401→DaemonAuthError); update halt proxy test; representative proxy-route tests (one mutating + one GET) asserting the auth-error JSON. +Commit: `feat(dashboard): forward control token on all daemon calls; map auth errors` + +### Task 7 — remaining clients +- `packages/briefing-summarizer/src/signals.ts` (~99): bearer from env when set. +- `packages/concierge/src/observer/daemon-poll.ts` (~45), `src/tools/ac.ts` (~18-38), `src/core/process-clients.ts` (~99-102): same. Update `src/tools/ac.test.ts` (~34-39) and `src/core/process-clients.test.ts` (~88-103) to assert the bearer when env set. +- `packages/daemon/src/main.ts` callApi (~139): bearer from env; if unset, read RUNFORGE_CONTROL_TOKEN from repo-root `.env.mac` if the file exists (simple line parse, no new dep). `packages/daemon/src/control-plane/cli.ts` (~62-66): if genuinely unimported by any entrypoint, delete it + its test; otherwise apply the same bearer logic. +Commit: `feat: all control-plane clients send the bearer token` + +### Task 8 — deployment plumbing (after 5+6+7) +- `docker-compose.yml`: add `RUNFORGE_CONTROL_TOKEN: ${RUNFORGE_CONTROL_TOKEN:?set RUNFORGE_CONTROL_TOKEN in the selected env file}` to daemon, dashboard, briefing-summarizer, and any composed concierge service (pattern of RUNFORGE_DOCKER_DATABASE_URL). +- `scripts/install-daemon.sh`: if RUNFORGE_CONTROL_TOKEN absent from `.env.mac`, generate `openssl rand -hex 32`, append (file kept 0600), substitute into plist. Idempotent. Support `RUNFORGE_ENV_MAC_PATH` override for testability. Acceptance check (RUN it): point the override at a temp file, run provisioning twice, assert identical token line + mode 0600 (skip launchctl). +- `scripts/com.runforge.daemon.plist`: add RUNFORGE_CONTROL_TOKEN placeholder in EnvironmentVariables. +- `scripts/sync-claude-creds.sh`: validation log → 0600 file inside $CREDS_DIR (or mktemp 0600), NOT /tmp/sync-claude-creds.validate. +- `.env.mac.example`, `.env.prod.example`, `docs/running.md`: document token, rotation, and the exact compose invocation `ENV_FILE=.env.mac docker compose --env-file .env.mac …` (both mechanisms required). +Commit: `ops: provision RUNFORGE_CONTROL_TOKEN across compose, launchd, docs; fix creds log perms` + +### Task 9 — dependency hygiene +- `pnpm audit --prod --audit-level high` to inventory. Upgrade `next` within v16 to latest patched; upgrade other direct deps where that clears advisories. +- Root `package.json` `"pnpm": { "overrides": { … } }` with minimal exact pins (resolve real versions from the registry) until `pnpm audit --prod --audit-level high` exits 0. Prefer classification/upgrade over override for dev-graph noise (better-auth peer graph pulling vitest/jsdom). +- Create `docs/security-overrides.md`: one line per override — package, pin, advisory ID, date. +- `pnpm install`, then `pnpm typecheck && pnpm test && pnpm build` — fix or drop any pin that breaks runtime; never ship red. +Commit: `chore(deps): clear high prod audit findings via upgrades + pnpm overrides` + +### Task 10 — CI security steps (inside the `ci` job) +File: `.github/workflows/ci.yml`. Add INSIDE the existing required `ci` job (a sibling job would NOT gate — requiredChecks is ["ci"]), right after `pnpm install --frozen-lockfile`: step `Audit (prod, high)` → `pnpm audit --prod --audit-level high`; step `Gitleaks (full history)` → download a pinned gitleaks release binary (verify sha256) and run `gitleaks detect --redact`; set the ci job's checkout `fetch-depth: 0`. Do NOT change requiredChecks. No job-level `services:`/`container:`/`uses: docker://…` (guard: `scripts/check-ci-workflows.mjs`). Verify `node scripts/check-ci-workflows.mjs` exits 0. +Commit: `ci: add security steps to ci job (prod audit gate + gitleaks full-history)` + +### Task 11 — full gates (last) +``` +pnpm typecheck && pnpm lint && pnpm test && pnpm build +pnpm check:traceability && pnpm check:workflows +pnpm audit --prod --audit-level high +``` +All green, acceptance tests green via verify_command. + +## Definition of done +- acceptance tests pass via verify_command; TDD for new code; do not modify acceptance tests +- all Task 11 gates green +- push branch `codex/control-plane-hardening-build` and open a PR against `main` (template below), then write result.md at result_path (frontmatter: status, pr, branch, verify_command_result; body: done list, risks, dead-ends) + +## PR template +Title: `security: control-plane bearer auth, dependency audit gate, secrets hardening` +Body: Summary of the four hardening areas (auth boundary, deps, secrets, spec coverage); link `docs/superpowers/specs/2026-07-07-control-plane-hardening-design.md` and `docs/superpowers/plans/2026-07-07-control-plane-hardening.md`; test plan checklist (acceptance tests, daemon matrix, dashboard tests, installer idempotence check, audit exit 0, gitleaks clean, check-ci-workflows green); note the deliberate docker fail-closed upgrade behavior and legacy loopback mode; end with: +🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/docs/superpowers/plans/2026-07-07-control-plane-hardening.md b/docs/superpowers/plans/2026-07-07-control-plane-hardening.md new file mode 100644 index 00000000..8b1f6cd8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-control-plane-hardening.md @@ -0,0 +1,157 @@ +# Control-Plane Hardening — Implementation Plan + +Spec: `docs/superpowers/specs/2026-07-07-control-plane-hardening-design.md` (codex-reviewed CLEAN, 5 iterations). Read it first; it is authoritative on behavior. This plan sequences the work. + +Branch: `codex/control-plane-hardening-build` (off this plan branch). All commands run from the repo root. Gate: acceptance tests in `packages/daemon/src/control-plane/__acceptance__/` (authored separately, immovable — do NOT modify them). They are plain Vitest files named `*.test.ts` so the daemon's default `vitest run` (root `src`, no custom include) picks them up. + +## Task 0 — Baseline + +```bash +pnpm install --frozen-lockfile +pnpm --filter @runforge/daemon test # must pass before starting +``` + +## Task 1 — `.specify` spec + traceability updates + +Files: `.specify/stack/operator-auth-ts.md` (STACK-AC-OPERATOR-AUTH), `.specify/architecture/operator-auth.md`, `.specify/functional/operator-auth.md`, `.specify/traceability.yml`. + +- Extend `STACK-AC-OPERATOR-AUTH` with the daemon control-plane auth model: bearer `RUNFORGE_CONTROL_TOKEN` on every route except `GET /health` (both servers); bind-host startup gate (IPv4-only contract, loopback = `127.0.0.0/8`; non-loopback + no token = refuse to start); legacy loopback mode (loopback + no token = start with loud warnings, `/halt` token-optional); `X-Requested-By` demoted to CSRF/provenance defense on mutating methods; built-in HTML dashboard = legacy/loopback-only. +- Update ARCH/FUNC parents only where they describe the boundary ("role enforcement happens in the dashboard" must become "dashboard enforces roles; daemon enforces the bearer boundary"). +- `traceability.yml`: under the operator-auth stack spec add `code_paths` for files that exist NOW: `packages/daemon/src/control-plane/server.ts`, `packages/daemon/src/control-plane/degraded-server.ts`, `packages/dashboard/lib/daemon-fetch.ts`, `docker-compose.yml`, `scripts/install-daemon.sh`, `scripts/com.runforge.daemon.plist`; `test_paths`: `packages/daemon/src/control-plane/server.test.ts`. (The `control-auth.ts`/`control-auth.test.ts` entries are added in Task 2, in the same commit that creates them — `pnpm check:traceability` must pass at every commit boundary.) +- Verify: `pnpm check:traceability` exits 0. + +Commit: `spec(operator-auth): daemon control-plane bearer boundary + traceability` + +## Task 2 — `control-auth.ts` (TDD) + +New: `packages/daemon/src/control-plane/control-auth.ts` + `control-auth.test.ts`. + +API: +```ts +export class ControlBindError extends Error {} +export function isLoopbackHost(host: string): boolean // IPv4 127.0.0.0/8 only (host contract is isIP===4) +export function assertBindAllowed(host: string, token: string | undefined): void + // non-loopback + (!token || token === '') → throw ControlBindError with actionable message + // loopback + no token → return (caller logs the legacy-mode warning) +export type AuthResult = { ok: true } | { ok: false; status: 401 | 403; error: string } +export function checkAuthorization(authorizationHeader: string | string[] | undefined, token: string): AuthResult + // missing header → 401; non-Bearer scheme or wrong value → 403 + // compare via Buffer byte-length check FIRST, then crypto.timingSafeEqual (it throws on length mismatch) +``` + +Tests (write first, red → green): loopback/non-loopback × token matrix for `assertBindAllowed`; `checkAuthorization` — valid; wrong same-length; wrong different-length (no throw); missing (401); `Basic` scheme (403); array header normalized. + +Also in this commit: add `control-auth.ts` + `control-auth.test.ts` to `.specify/traceability.yml` (see Task 1 note); `pnpm check:traceability` exits 0. + +Commit: `feat(daemon): control-plane auth primitives (bind gate + bearer check)` + +## Task 3 — `server.ts` enforcement + +File: `packages/daemon/src/control-plane/server.ts`. + +- Read `RUNFORGE_CONTROL_TOKEN` once per request handling (keep current per-request `process.env` read semantics so tests can toggle it — mirror how `/halt` reads it today at :152). +- In the top-level request handler, BEFORE route dispatch and body reads: if token configured → `checkAuthorization(req.headers.authorization, token)`; on failure respond 401/403 JSON `{error}` for every path except `GET /health`. If token not configured (legacy loopback mode — non-loopback can't reach here, see Task 5) → allow, but log a rate-limited warning (at most once per minute) naming the deprecation. +- Keep the existing `X-Requested-By` presence check for POST/PUT (runs AFTER bearer). +- `/halt`: remove its ad-hoc token block (now covered globally); preserve its semantics — with no token configured it remains reachable with only the CSRF header. +- `createControlServer(port, handlers, host)`: call `assertBindAllowed(host, process.env.RUNFORGE_CONTROL_TOKEN)` before `listen` (defense in depth). +- Update `server.test.ts`: request matrix {token set, unset} × {mutating POST, sensitive GET (`/status`, `/decisions/pending`), `GET /health`, `/halt`} → {2xx/expected, 401 missing, 403 wrong}; env save/restore per `phase0-halt.gate.test.ts:15,102-104` pattern; REWRITE `server.test.ts:499-504` (currently asserts tokenless `0.0.0.0` succeeds) to set a token, and add a fail-closed assertion (`ControlBindError`) for the tokenless non-loopback case. + +Commit: `feat(daemon): require bearer on control plane; fail closed off-loopback` + +## Task 4 — `degraded-server.ts` enforcement + +File: `packages/daemon/src/control-plane/degraded-server.ts` (+ its test file, or `degraded-server.test.ts` if none). + +- Same rule via `checkAuthorization`: `/status` requires bearer when token set; `GET /health` always open. `assertBindAllowed` before listen. + +Commit: `feat(daemon): degraded server honors control token` + +## Task 5 — daemon startup gate + +File: `packages/daemon/src/control-plane/daemon.ts` (host resolution ~:604-612, degraded server start ~:672-678). + +- After resolving `daemonHost`, call `assertBindAllowed(daemonHost, process.env.RUNFORGE_CONTROL_TOKEN)`; surface `ControlBindError` as a fatal startup error with the actionable message (set token or bind loopback). When loopback + no token → log the legacy-mode startup warning (once). +- Test at daemon level (in `daemon.test.ts` or a focused new test): non-loopback + no token refuses startup; loopback + no token starts with warning. + +Commit: `feat(daemon): refuse non-loopback bind without control token` + +## Task 6 — dashboard client + proxy sweep + +Files: `packages/dashboard/lib/daemon-fetch.ts`, `packages/dashboard/app/(dashboard)/page.tsx`, `packages/dashboard/app/api/daemon/halt/route.ts`, all `daemonFetch` callers. + +- `daemonFetch`: when `process.env.RUNFORGE_CONTROL_TOKEN` set, set `Authorization: Bearer ` AFTER merging caller headers (caller cannot override). On daemon 401/403 throw new typed `DaemonAuthError` (message: control token missing or invalid — set `RUNFORGE_CONTROL_TOKEN` in the dashboard environment). Export it next to `DaemonConfigError`. +- `page.tsx:24`: replace direct `fetch(${DAEMON_URL}/status)` with `daemonFetch('/status', …)`, keep error handling. +- `halt/route.ts`: delete the ad-hoc bearer injection (:31-38) — daemonFetch owns it. +- Sweep EVERY `daemonFetch` caller (grep is authoritative). API routes: add `DaemonAuthError` handling returning 500-family JSON with the actionable message. Known floor: `app/api/daemon/{status,pause,resume,halt,release,issues/scan,remote-control/restart,repos-reload}/route.ts`, `app/api/decisions/pending/route.ts`, `app/api/decisions/[id]/route.ts`, `app/api/decisions/answer/route.ts`, `app/api/decisions/[id]/reveal/route.ts`, `app/api/metrics/escalation/route.ts`. Non-route callers — server actions (`actions/repos.ts:40`, `actions/github-connections.ts:9`) and server components (`app/(dashboard)/metrics/page.tsx:22`, `app/(dashboard)/steering/page.tsx:37`, `app/(dashboard)/page.tsx`): treat `DaemonAuthError` exactly like their existing `DaemonConfigError`/unreachable handling (degrade to offline/error state), but include the auth message so the operator can distinguish misconfiguration from a down daemon. +- Tests: daemon-fetch unit tests (bearer on GET+POST when set; absent when unset; not overridable; 401→DaemonAuthError); update halt proxy test; representative proxy-route tests (one mutating, one GET) asserting the auth-error JSON. + +Commit: `feat(dashboard): forward control token on all daemon calls; map auth errors` + +## Task 7 — remaining clients + +- `packages/briefing-summarizer/src/signals.ts:99`: add bearer header from `process.env.RUNFORGE_CONTROL_TOKEN` when set. +- `packages/concierge/src/observer/daemon-poll.ts:45`, `src/tools/ac.ts:18-38`, `src/core/process-clients.ts:99-102`: same. Update `src/tools/ac.test.ts:34-39` and `src/core/process-clients.test.ts:88-103` (assert bearer when env set). +- `packages/daemon/src/main.ts` `callApi` (:139): bearer from env; if unset, read `RUNFORGE_CONTROL_TOKEN` from repo-root `.env.mac` if the file exists (simple line parse, no new dep). `packages/daemon/src/control-plane/cli.ts:62-66`: if genuinely unimported by any entrypoint, delete it + its test; otherwise apply the same bearer logic. + +Commit: `feat: all control-plane clients send the bearer token` + +## Task 8 — deployment plumbing + +- `docker-compose.yml`: add `RUNFORGE_CONTROL_TOKEN: ${RUNFORGE_CONTROL_TOKEN:?set RUNFORGE_CONTROL_TOKEN in the selected env file}` to daemon, dashboard, briefing-summarizer, **and any concierge service if one is composed** (grep the compose file — every service whose code calls the daemon; pattern of `RUNFORGE_DOCKER_DATABASE_URL`). +- `scripts/install-daemon.sh`: if `RUNFORGE_CONTROL_TOKEN` absent from `.env.mac`, generate `openssl rand -hex 32`, append to `.env.mac` (file kept 0600), and substitute into the plist. Idempotent: re-run reuses the existing token. Support `RUNFORGE_ENV_MAC_PATH` override for the env-file location so idempotence is testable without touching the real `.env.mac`. **Acceptance check (run it):** point `RUNFORGE_ENV_MAC_PATH` at a temp file, run the token-provisioning step twice, assert the token line is identical both times and the file mode is 0600 (a small shell check is fine; skip launchctl in the check). +- `scripts/com.runforge.daemon.plist`: add `RUNFORGE_CONTROL_TOKEN` placeholder in `EnvironmentVariables`. +- `scripts/sync-claude-creds.sh`: validation log → 0600 file inside `$CREDS_DIR` (or `mktemp` with 0600), not `/tmp/sync-claude-creds.validate`. +- `.env.mac.example`, `.env.prod.example`, `docs/running.md`: document the token, rotation, and the exact compose invocation `ENV_FILE=.env.mac docker compose --env-file .env.mac …` (both mechanisms required — interpolation vs service `env_file`). + +Commit: `ops: provision RUNFORGE_CONTROL_TOKEN across compose, launchd, docs; fix creds log perms` + +## Task 9 — dependency hygiene + +```bash +pnpm audit --prod --audit-level high # inventory first +``` +- Upgrade `next` within v16 to latest patched; upgrade other direct deps where that clears advisories. +- Add root `package.json` `"pnpm": { "overrides": { … } }` with minimal exact pins (resolve versions from the registry NOW — path-to-regexp, picomatch, fast-uri, undici, vite, hono as needed) until `pnpm audit --prod --audit-level high` exits 0. Prefer classification/upgrade over override for dev-graph noise (better-auth peer graph pulling vitest/jsdom). +- Create `docs/security-overrides.md`: one line per override — package, pin, advisory ID, date. +- `pnpm install` (lockfile updates), then full `pnpm typecheck && pnpm test && pnpm build` — overrides can break runtime deps; fix or drop the offending pin (never ship red). + +Commit: `chore(deps): clear high prod audit findings via upgrades + pnpm overrides` + +## Task 10 — CI security steps (inside the `ci` job) + +File: `.github/workflows/ci.yml`. + +- **Gating constraint:** the autonomous merge gate polls only the exact required check names (`runforge.config.json:60` → `requiredChecks: ["ci"]`; `packages/daemon/src/control-plane/await-checks.ts:104`). A sibling job would NOT block landing. Therefore add the security steps **inside the existing `ci` job**, early (right after `pnpm install --frozen-lockfile`, before lint): step `Audit (prod, high)` → `pnpm audit --prod --audit-level high`; step `Gitleaks (full history)` → download a pinned gitleaks release binary (verify sha256 checksum) and run `gitleaks detect --redact`. The `ci` job's checkout needs `fetch-depth: 0` for full-history scanning. Do NOT change `requiredChecks` (that is deployment policy). CONSTRAINT: no job-level `services:`/`container:`/`uses: docker://…` (`scripts/check-ci-workflows.mjs` guard). +- Verify locally: `node scripts/check-ci-workflows.mjs` exits 0. + +Commit: `ci: add security steps to ci job (prod audit gate + gitleaks full-history)` + +## Task 11 — full gates + +```bash +pnpm typecheck && pnpm lint && pnpm test && pnpm build +pnpm check:traceability && pnpm check:workflows +gitleaks detect --redact +pnpm audit --prod --audit-level high # exit 0 +``` +All green before opening the PR. + +## Dependency order + +Task 1 independent. Task 2 → {3,4,5}. Task 6,7 depend on 3 (behavior locked). Task 8 depends on 5, 6 AND 7 — never token-enable the composed daemon before every client forwards the bearer. Task 9,10 independent. Task 11 last. + +## Verification design (Phase 9, post-merge, run by conductor) + +1. Start daemon locally with `RUNFORGE_CONTROL_TOKEN=testtoken` (loopback): `curl -s -o /dev/null -w '%{http_code}' localhost:3847/status` → 401; with `-H 'Authorization: Bearer testtoken'` → 200; `POST /pause` with bearer+`X-Requested-By` → 200; `GET /health` tokenless → 200. +2. `DAEMON_HOST=0.0.0.0` without token → process exits with ControlBindError message. +3. Tokenless loopback start → warning logged, requests work (legacy mode). +4. `pnpm audit --prod --audit-level high` → exit 0. On the PR's CI run, the `ci` job contains green `Audit (prod, high)` and `Gitleaks (full history)` steps. +5. Dashboard (if running): status page renders via daemonFetch with bearer. + +## Follow-up issues to file (Phase 8/9, `gh issue create`) + +1. Extract server.ts routing/middleware/handlers (mechanical refactor). +2. Decompose daemon.ts startDaemon() into phase modules. +3. Operator decision: widen gate1Commands + set baselinePreexistingFailures:false. +4. Remove legacy loopback unauthenticated mode after one release cycle. +5. Remove or properly auth the built-in HTML dashboard (control-plane/dashboard.ts). diff --git a/docs/superpowers/specs/2026-07-07-control-plane-hardening-design.md b/docs/superpowers/specs/2026-07-07-control-plane-hardening-design.md new file mode 100644 index 00000000..81df7829 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-control-plane-hardening-design.md @@ -0,0 +1,117 @@ +# Control-Plane Hardening — Design Spec + +Date: 2026-07-07 · Status: draft → codex-reviewed · Author: Claude (sparring-driven-development) + +## Goal + +Address the production-readiness findings from the 2026-07-07 external engineering review in one focused hardening PR: + +1. **Auth boundary:** the daemon control plane guards mutating routes with a presence-only `X-Requested-By` header; only `/halt` optionally checks a bearer token. Sensitive actions (`/resume`, `/decisions/:id/reveal`, `/remote-control/restart`, `/release`, `/deployments/:id/widen`, `/retry/:id`, `PUT /spend/pricing-reference`) are effectively unauthenticated inside the network boundary. +2. **Dependency hygiene:** `pnpm audit --prod` reports 68 vulnerabilities (20 high). No `pnpm.overrides` exist; CI has no audit or secret-scan job. +3. **Secrets handling:** `sync-claude-creds.sh` writes a validation log to world-readable `/tmp`; the launchd installer does not provision a control token at all. +4. **Spec coverage gap:** `FUNC/ARCH/STACK-AC-OPERATOR-AUTH` do not own the daemon-side control-plane auth model (`server.ts` is traced only under `STACK-AC-RUNTIME-SOURCE-ISOLATION`). + +**Explicitly out of scope (filed as follow-up issues, not built here):** decomposing `server.ts` (790 lines) and `daemon.ts` (4,271 lines); changing `runforge.config.json` pipeline policy (`gate1Commands`, `baselinePreexistingFailures`). Rationale (codex round 1): refactors dilute a security diff on a RED-risk path; config flags are live release-policy changes, not honesty edits. + +## Current State (verified 2026-07-07) + +- `packages/daemon/src/control-plane/server.ts:94-101` — POST/PUT without `x-requested-by` → 403. Value never validated. GETs unguarded. +- `server.ts:147-177` — `/halt` requires `Authorization: Bearer $RUNFORGE_CONTROL_TOKEN` **only if** the env var is set; unset → reachable with CSRF header alone ("fail-safe toward halting"). +- `server.ts:552-554` — comment: daemon is not role-aware; dashboard enforces admin-only. +- Bind: `createControlServer(port, handlers, host='127.0.0.1')` (`server.ts:85-89`); Docker sets `DAEMON_HOST=0.0.0.0` on the internal `app` network, `ports: []` (nothing published). Compose sets **no** `RUNFORGE_CONTROL_TOKEN`. +- `packages/dashboard/lib/daemon-fetch.ts` — the intended choke-point client; injects `X-Requested-By: dashboard`; no bearer logic. Only `app/api/daemon/halt/route.ts` forwards the bearer, ad hoc. +- **daemonFetch is NOT the only caller.** Direct control-plane callers that must not break: `packages/dashboard/app/(dashboard)/page.tsx:24` (direct `fetch(${DAEMON_URL}/status)`), `packages/briefing-summarizer/src/signals.ts:99` (`GET /status`), `packages/concierge/src/observer/daemon-poll.ts:45` (`GET /status`), and the daemon CLI itself — `packages/daemon/src/main.ts` `callApi()` (:139) hits `http://127.0.0.1:` for `GET /status`, `GET /health`, `POST /pause|/resume|/retry/:issue` with only `X-Requested-By: cli`. +- **A second server exists:** `packages/daemon/src/control-plane/degraded-server.ts:36-43` serves unauthenticated `GET /health` and `GET /status` during degraded startup; it is started with the same resolved host (`daemon.ts:672-678`). The effective bind host is resolved at `daemon.ts:604-607` (`DAEMON_HOST ?? config.controlHost ?? default`) and **rejected unless `isIP(host) === 4`** (`daemon.ts:608-612`) — the daemon has an IPv4-only host contract; `localhost`/`::1` are already invalid values today. +- `scripts/install-daemon.sh` — seds `GITHUB_TOKEN`, `RUNFORGE_DATABASE_URL`, `ENCRYPTION_KEY` into the launchd plist (0600); does not provision `RUNFORGE_CONTROL_TOKEN`. +- `scripts/sync-claude-creds.sh` — creds file itself is 0600+atomic (fine); validation log goes to world-readable `/tmp/sync-claude-creds.validate`. +- Root `package.json` / `pnpm-workspace.yaml` — no overrides mechanism at all. `next@16.2.0` in `packages/dashboard`. +- `.github/workflows/ci.yml` — guard + lint/typecheck/test/flake-probe/e2e/build. No audit, no gitleaks. A `.gitleaks.toml` exists and `gitleaks detect --redact` passes locally today. + +## Chosen Design (codex pick: "A+", 2 rounds) + +### D1. Daemon-side bearer auth on the control plane + +- **Token:** `RUNFORGE_CONTROL_TOKEN` (existing var, widened role — no new var). +- **Shared module:** implement the auth pieces once in a new `packages/daemon/src/control-plane/control-auth.ts` — `assertBindAllowed(host, token)` (startup gate) and `isAuthorized(req, token)` (request check) — consumed by **both** `server.ts` (`createControlServer`) and `degraded-server.ts`. The degraded server must not remain an unauthenticated bypass: its `/status` follows the same rule; `/health` stays exempt. +- **Startup gate (bind-host based, not per-request):** the daemon's IPv4-only host contract stands (`isIP(host) === 4`, `daemon.ts:608-612`); loopback therefore means an IPv4 `127.0.0.0/8` address (in practice `127.0.0.1`). If the effective bind host is non-loopback and the token is unset/empty → **refuse to start** with an actionable error. Enforced where the host is resolved (`daemon.ts:604-612`) before either server starts, and re-asserted inside `createControlServer`/`createDegradedServer` via `assertBindAllowed` (defense in depth — tests and other callers construct these servers directly). Docker's internal bridge is non-loopback and gets no exemption: "internal app network" is not an auth boundary. +- **Legacy loopback mode:** loopback bind + no token → start, but emit a loud startup warning and a per-request warning (rate-limited) that unauthenticated control-plane access is deprecated. This prevents bricking existing native installs whose dashboard doesn't yet send the bearer. +- **Request rule (token configured):** every route **except `GET /health`** requires `Authorization: Bearer `. Full inventory (keep the "everything except /health" rule authoritative; this list is for the test matrix): mutating POST — `/pause`, `/halt`, `/resume`, `/drain`, `/drain/cancel`, `/repos/reload`, `/remote-control/restart`, `/issues/scan`, `/release`, `/release/preview`, `/release/completion`, `/ideas`, `/po/interactive-session`, `/decisions/:id/answer`, `/decisions/:id/reveal`, `/deployments/:id/widen`, `/retry/:id`; mutating PUT — `/spend/pricing-reference`; sensitive GET — `/status`, `/dashboard`, `/api/runs`, `/decisions/pending`, `/decisions/:id`, `/metrics/escalation`, `/spend/*`; degraded-server GET `/status`. Read exposure of decisions/spend/run metadata to co-resident containers is in scope. `GET /health` never requires auth (compose healthcheck/liveness) on either server. +- **`/halt` semantics preserved:** in legacy loopback mode `/halt` stays token-optional (the deliberate safe-stop escape hatch). Once a token is configured, `/halt` requires it like everything else. The non-loopback+no-token case no longer exists (refuse-to-start). +- **Mechanics:** shared `isAuthorized(req)` check before route dispatch and before body reads. Normalize the `Authorization` header to a single string; require exact `Bearer ` scheme; compare byte lengths first, then `crypto.timingSafeEqual` (it throws on length mismatch). 401 when header missing, 403 when invalid. +- **`X-Requested-By` is kept** for mutating methods as CSRF/provenance defense, demoted from "the auth" to defense-in-depth. Bearer check runs first. +- **Audit-log actor fallback** (`server.ts:555-562`) unchanged. + +### D2. Clients — every control-plane caller sends the bearer + +- **daemonFetch** injects `Authorization: Bearer $RUNFORGE_CONTROL_TOKEN` on **all** requests (GET included) when the env var is set; the header is set after caller headers so it cannot be overridden. Remove the ad-hoc bearer logic from `app/api/daemon/halt/route.ts`. +- **`app/(dashboard)/page.tsx`**: replace the direct `fetch(${DAEMON_URL}/status)` with `daemonFetch('/status', …)` so it inherits the bearer (keep its existing error handling for `DaemonConfigError`). +- **briefing-summarizer** (`src/signals.ts:99`) and **concierge** — ALL of its daemon call sites: `src/observer/daemon-poll.ts:45`, `src/tools/ac.ts:18-38` (`GET /status`, `POST /pause`, `POST /retry` — live via `src/core/runtime.ts:279-286`), `src/core/process-clients.ts:99-102` (`GET /status`): add the bearer header from `process.env.RUNFORGE_CONTROL_TOKEN` when set. Update the tests asserting the unauthenticated shape (`src/tools/ac.test.ts:34-39`, `src/core/process-clients.test.ts:88-103`). +- **daemon CLI** (`main.ts` `callApi`, and the second tested implementation in `control-plane/cli.ts:62-66` — update both or delete the stale one if truly unused, with its test): send the bearer from `process.env.RUNFORGE_CONTROL_TOKEN`; if unset, fall back to reading `RUNFORGE_CONTROL_TOKEN` from the repo-root `.env.mac` when the file exists (the operator's interactive shell won't have the launchd plist env). `/health` keeps working tokenless either way. +- **Built-in HTML dashboard** (`control-plane/dashboard.ts:128-135` does browser-side unauthenticated relative fetches to `/status`/`/api/runs`; served at `server.ts:103-106`): declared **legacy/loopback-only**. It keeps working when no token is configured (legacy loopback mode); in token mode its endpoints require the bearer like everything else, so the browser page becomes non-functional by design — the Next.js dashboard is the real UI. Document this in the spec/docs; removal of the built-in page is a follow-up. No token-in-query-string or cookie scheme is added for it. +- No module-load/startup hard failure in Next (breaks builds/tests/serverless). **Error-mapping owner: `daemonFetch`** — when the daemon returns 401/403, throw a typed `DaemonAuthError` ("control token missing or invalid — set RUNFORGE_CONTROL_TOKEN in the dashboard environment") alongside the existing `DaemonConfigError`; **every** route that calls `daemonFetch` catches it and returns a 500-family JSON with that actionable message — sweep all `DaemonConfigError`-only catch blocks (grep `daemonFetch` callers; known: `app/api/daemon/{status,pause,resume,halt,release,issues/scan,remote-control/restart,repos-reload}/route.ts`, `app/api/decisions/pending/route.ts:43`, `app/api/decisions/[id]/route.ts:39`, `app/api/decisions/answer/route.ts:58`, `app/api/decisions/[id]/reveal/route.ts:111`, `app/api/metrics/escalation/route.ts:43` — the grep sweep is authoritative, the list is a floor, not the ceiling) so auth failures don't collapse into generic "Daemon unreachable"/offline responses. Tested at the daemonFetch level plus representative proxy routes (at minimum one mutating + one GET). + +### D3. Deployment plumbing + +- **docker-compose.yml:** every service that calls the daemon — daemon (containerized-daemon profile), dashboard, briefing-summarizer, and concierge if composed — gets `RUNFORGE_CONTROL_TOKEN: ${RUNFORGE_CONTROL_TOKEN:?set RUNFORGE_CONTROL_TOKEN in the selected env file}`, following the established `RUNFORGE_DOCKER_DATABASE_URL:?` pattern (services already use `env_file: ${ENV_FILE:-.env.prod}` for delivery; the `${…:?}` interpolation reads the shell/`--env-file`, not `env_file:` — same invocation contract as today, document it in `.env.prod.example`). +- **install-daemon.sh:** generate a token if absent (`openssl rand -hex 32`), persist it into `.env.mac` (0600), inject into the plist like the other vars. `.env.mac` is the local SSOT; the dashboard/summarizer containers on the same host consume it by launching compose with **both** mechanisms set — `ENV_FILE=.env.mac docker compose --env-file .env.mac …` (`--env-file` feeds the `${…:?}` interpolation; the `ENV_FILE` shell var feeds each service's `env_file:` — one alone is NOT sufficient, see `docker-compose.yml:64-69`). Document this exact invocation in `docs/running.md`/`.env.prod.example`. Rotation = update env source, reinstall/reload daemon, restart dashboard (documented). +- **com.runforge.daemon.plist:** add the `RUNFORGE_CONTROL_TOKEN` placeholder. +- **Env examples/docs:** `.env.mac.example`, `.env.prod.example`, `docs/running.md` updated. + +### D4. Dependency hygiene + +- Add overrides in the **root `package.json` nested field `"pnpm": { "overrides": { … } }`** (pnpm@10; NOT a top-level `overrides` key, NOT `pnpm-workspace.yaml`) with **minimal, exact pins** for the vulnerable transitive packages (path-to-regexp, picomatch, fast-uri, undici, vite, hono, …) and upgrade direct deps (`next` within v16) until **`pnpm audit --prod --audit-level high` exits 0**. Exact versions are resolved at implementation time from the registry — the plan does not invent version numbers. `package.json` is strict JSON (no comments): record each override's advisory ID in `docs/security-overrides.md` (one line per override: package, pinned version, advisory, date) and in the PR body. If a "prod" finding is actually dev-graph noise (better-auth's peer graph attaching vitest/jsdom), fix classification/upgrade rather than blanket-override. +- **CI:** security steps **inside the existing required `ci` job** (NOT a sibling job — the autonomous merge gate polls only `requiredChecks: ["ci"]` per `runforge.config.json:60` / `await-checks.ts:104`, so a sibling job would not block landing): `pnpm audit --prod --audit-level high` (gate: exit 0, no allowlist) + full-history `gitleaks detect --redact` (no baseline; repo is currently clean; requires `fetch-depth: 0`). **Constraint from `scripts/check-ci-workflows.mjs:9-13,104-115`:** the guard forbids job-level `services:`, `container:`, and `uses: docker://…` — install gitleaks as a plain shell step (download the release binary, or `docker run` in a shell step like the existing Postgres pattern at `ci.yml:66-87`); never a Docker action/service container. Full-history catches committed-then-removed secrets; revisit commit-range scanning only if runtime hurts. + +### D5. Secrets fix + +- `sync-claude-creds.sh`: write the validation log to a 0600 file inside the creds dir (or a `mktemp` 0600 path), never world-readable `/tmp`. + +### D6. Spec/traceability alignment (required by repo governance) + +- Extend `STACK-AC-OPERATOR-AUTH` (and its ARCH/FUNC parents where behavior is described) to own the daemon control-plane auth: bearer requirement, bind-host startup gate, legacy loopback mode, `/health` exemption, `X-Requested-By` demotion. +- `.specify/traceability.yml`: add `server.ts`, `degraded-server.ts`, `control-auth.ts`, `daemon-fetch.ts`, compose/installer files under the operator-auth spec's `code_paths`; add `server.test.ts` (and the new auth test files) to `test_paths`. + +## Rejected Alternatives + +- **B (bundle server.ts split):** dilutes the security diff on a RED-risk path; do after auth tests lock behavior. → follow-up issue. +- **C (split daemon.ts):** ~3,585-line `startDaemon()`; high regression risk, weeks of work. → follow-up issue. +- **D (bundle config-policy cleanup):** `gate1Commands`/`baselinePreexistingFailures` alter the live pipeline's operating contract — release-policy decisions for the Operator, not hardening. → follow-up issue tagged for Operator decision. +- **Per-request remote-address gating:** proxies/docker NAT make remote addresses unreliable; bind-host at startup is deterministic. +- **mTLS / dashboard-role-aware daemon:** overkill for a single-operator control plane; bearer + network isolation + dashboard RBAC is proportionate. + +## Backward Compatibility / Rollout + +| Deployment | Before | After upgrade | +|---|---|---| +| Native (loopback, no token) | works, unauthenticated | works, loud deprecation warnings; installer provisions token on next `install-daemon.sh` run | +| Docker (0.0.0.0, no token) | works, unauthenticated | compose interpolation fails fast with actionable message until token set in env file — **deliberate fail-closed**; documented in PR body + `.env.prod.example` | +| Docker (token set) | only /halt guarded | all routes guarded end-to-end | + +## Test Strategy + +- **`control-auth.test.ts` (new, unit):** `assertBindAllowed` matrix — {127.0.0.1, 0.0.0.0, other IPv4} × {token set, unset} → {ok, ok-with-warning, throw}; `isAuthorized` — valid token, wrong token same length, wrong token different length (no throw — byte-length check before `timingSafeEqual`), missing header (401), wrong scheme (403). +- **`server.test.ts`:** request-level matrix with the server constructed directly (as today, `server.test.ts:61-67`): {token set, unset} × {mutating route, sensitive GET, `/health`, `/halt`} → {2xx, 401, 403}. **Env hygiene:** save/restore `RUNFORGE_CONTROL_TOKEN` per test following the existing pattern in `phase0-halt.gate.test.ts:15,102-104` — the current `afterEach` only closes the server. **Existing-test fallout:** `server.test.ts:499-504` asserts `createControlServer(..., '0.0.0.0')` succeeds with no token — rewrite it to set a token (and add the fail-closed assertion for the tokenless case). +- **Degraded server:** `/status` requires bearer when token set; `/health` open — in its own test file or `daemon.test.ts` (the startup-gate/host-resolution behavior lives at daemon level, not in `server.test.ts`, which never exercises host resolution). +- **`daemon-fetch` tests:** bearer present on GET+POST when env set; absent when unset; caller cannot override the header. Halt proxy route test updated (bearer now via daemonFetch). +- **Client call sites:** briefing-summarizer/concierge/CLI — bearer attached when env set (unit-level where tests exist). +- Shell: installer idempotence (token generated once, reused on re-run) — grep-able acceptance checks at minimum. +- The CI security steps prove themselves on the PR's `ci` job run (audit exit 0, gitleaks clean). + +## Risks + +- **Upgrade friction (docker):** intentional fail-fast; mitigation = clear compose error text + docs. +- **Override pins drift:** overrides can mask future legit upgrades; mitigation = record each override's advisory ID in `docs/security-overrides.md` (JSON forbids comments). +- **better-auth prod-audit noise:** may require upstream upgrade instead of override; time-boxed — if a high advisory is unfixable without breaking better-auth, document as accepted-risk in the PR (gate would then need that single advisory ignored via `pnpm audit` ignore mechanism, commented with expiry). +- **Legacy mode lingering forever:** follow-up issue includes removing legacy loopback mode after one release cycle. + +## Follow-ups (filed as issues in Phase 8/9) + +1. Extract `server.ts` routing/middleware/handlers (mechanical, after auth tests lock behavior). +2. Decompose `daemon.ts` `startDaemon()` into phase modules. +3. Operator decision: `gate1Commands` widening + `baselinePreexistingFailures:false`. +4. Remove legacy loopback unauthenticated mode after one release cycle. +5. Remove the built-in HTML dashboard (`control-plane/dashboard.ts`) or give it a proper auth story; it is legacy/loopback-only as of this change. + +## Open Questions for Operator + +- None blocking. Config-policy change (follow-up 3) is an Operator release-policy decision by design. diff --git a/package.json b/package.json index 56c34633..dd48c9f6 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,16 @@ }, "devDependencies": { "husky": "^9.1.7" + }, + "pnpm": { + "overrides": { + "micromatch>picomatch": "2.3.2", + "path-to-regexp": "8.4.2", + "fast-uri": "3.1.3", + "vitest>picomatch": "4.0.5", + "vitest>vite": "7.3.6", + "jsdom>undici": "7.28.0", + "@dotenvx/dotenvx>picomatch": "4.0.5" + } } } diff --git a/packages/briefing-summarizer/src/signals.ts b/packages/briefing-summarizer/src/signals.ts index b8c8db0c..cda748e3 100644 --- a/packages/briefing-summarizer/src/signals.ts +++ b/packages/briefing-summarizer/src/signals.ts @@ -94,10 +94,16 @@ export async function collectSignals( async function collectDaemonStatus(daemonUrl: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5_000); + const token = process.env.RUNFORGE_CONTROL_TOKEN; + const headers: Record = {}; + if (token !== undefined && token !== '') { + headers.Authorization = `Bearer ${token}`; + } try { const res = await fetch(`${daemonUrl}/status`, { signal: controller.signal, + headers, }); if (!res.ok) throw new Error(`Daemon status returned ${res.status}`); return (await res.json()) as DaemonStatus; diff --git a/packages/concierge/package.json b/packages/concierge/package.json index ddcc0446..3f5b31fc 100644 --- a/packages/concierge/package.json +++ b/packages/concierge/package.json @@ -23,6 +23,6 @@ }, "dependencies": { "@hono/node-server": "^1.19.11", - "hono": "^4.12.8" + "hono": "^4.12.28" } } diff --git a/packages/concierge/src/core/process-clients.test.ts b/packages/concierge/src/core/process-clients.test.ts index 959973f2..19743bf2 100644 --- a/packages/concierge/src/core/process-clients.test.ts +++ b/packages/concierge/src/core/process-clients.test.ts @@ -116,6 +116,40 @@ describe('process runtime clients', () => { ]); }); + it('sends Authorization Bearer when RUNFORGE_CONTROL_TOKEN is set', async () => { + const originalToken = process.env.RUNFORGE_CONTROL_TOKEN; + process.env.RUNFORGE_CONTROL_TOKEN = 'secrettoken'; + + try { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const client = createObserverProcessClient({ + runforgeBaseUrl: 'http://127.0.0.1:3847', + watchedRepos: [], + fetch: async (url, init) => { + requests.push({ url: String(url), init }); + return new Response(JSON.stringify({ paused: true }), { status: 200 }); + }, + execFile: async () => ({ stdout: '' }), + }); + + await client.daemonState(); + + expect(requests[0]).toEqual({ + url: 'http://127.0.0.1:3847/status', + init: { + method: 'GET', + headers: { Authorization: 'Bearer secrettoken' }, + }, + }); + } finally { + if (originalToken === undefined) { + delete process.env.RUNFORGE_CONTROL_TOKEN; + } else { + process.env.RUNFORGE_CONTROL_TOKEN = originalToken; + } + } + }); + it('uses Mail through osascript for drafts and confirmed sends', async () => { const calls: Array<{ file: string; args: string[] }> = []; const client = createMailAppleScriptClient({ diff --git a/packages/concierge/src/core/process-clients.ts b/packages/concierge/src/core/process-clients.ts index 29558351..50e8e051 100644 --- a/packages/concierge/src/core/process-clients.ts +++ b/packages/concierge/src/core/process-clients.ts @@ -97,8 +97,14 @@ export function createObserverProcessClient(options: { }, daemonState: async () => { + const token = process.env.RUNFORGE_CONTROL_TOKEN; + const headers: Record = {}; + if (token !== undefined && token !== '') { + headers.Authorization = `Bearer ${token}`; + } const response = await options.fetch(`${options.runforgeBaseUrl.replace(/\/+$/, '')}/status`, { method: 'GET', + headers, }); return readJsonBody(response); }, diff --git a/packages/concierge/src/observer/daemon-poll.ts b/packages/concierge/src/observer/daemon-poll.ts index f8a6ff1d..d80d7198 100644 --- a/packages/concierge/src/observer/daemon-poll.ts +++ b/packages/concierge/src/observer/daemon-poll.ts @@ -40,9 +40,14 @@ export function createDaemonStatusPoller(options: DaemonStatusPollerOptions): Da export function createDaemonStatusHttpClient(options: DaemonStatusHttpClientOptions): DaemonStatusClient { const fetchImpl = options.fetch ?? fetch; const baseUrl = options.baseUrl.replace(/\/+$/, ''); + const token = process.env.RUNFORGE_CONTROL_TOKEN; + const headers: Record = {}; + if (token !== undefined && token !== '') { + headers.Authorization = `Bearer ${token}`; + } return { async status(): Promise { - const response = await fetchImpl(`${baseUrl}/status`, { method: 'GET' }); + const response = await fetchImpl(`${baseUrl}/status`, { method: 'GET', headers }); if (!response.ok) throw new Error(`daemon status failed: ${response.status}`); const text = await response.text(); if (!text) return {}; diff --git a/packages/concierge/src/tools/ac.test.ts b/packages/concierge/src/tools/ac.test.ts index 098875e1..fa85b504 100644 --- a/packages/concierge/src/tools/ac.test.ts +++ b/packages/concierge/src/tools/ac.test.ts @@ -40,6 +40,42 @@ describe('runforge tool handlers', () => { }); }); + it('sends Authorization Bearer when RUNFORGE_CONTROL_TOKEN is set', async () => { + const originalToken = process.env.RUNFORGE_CONTROL_TOKEN; + process.env.RUNFORGE_CONTROL_TOKEN = 'secrettoken'; + + try { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const handlers = createRunforgeToolHandlers({ + baseUrl: 'http://daemon', + requestedBy: 'concierge-test', + fetch: async (url, init) => { + requests.push({ url: String(url), init }); + return new Response(JSON.stringify({ retrying: 504 }), { status: 200 }); + }, + }); + + await handlers.ac_unstuck({ issue: 504 }, { conversationId: 'c1', toolCallId: 't1' }); + + expect(requests[0]).toEqual({ + url: 'http://daemon/retry/504', + init: { + method: 'POST', + headers: { + Authorization: 'Bearer secrettoken', + 'X-Requested-By': 'concierge-test', + }, + }, + }); + } finally { + if (originalToken === undefined) { + delete process.env.RUNFORGE_CONTROL_TOKEN; + } else { + process.env.RUNFORGE_CONTROL_TOKEN = originalToken; + } + } + }); + it('throws readable errors for non-2xx daemon responses', async () => { const handlers = createRunforgeToolHandlers({ baseUrl: 'http://daemon', diff --git a/packages/concierge/src/tools/ac.ts b/packages/concierge/src/tools/ac.ts index b31ca2fc..41630f88 100644 --- a/packages/concierge/src/tools/ac.ts +++ b/packages/concierge/src/tools/ac.ts @@ -14,10 +14,25 @@ export function createRunforgeToolHandlers( ): Record<'ac_status' | 'ac_pause' | 'ac_unstuck' | 'ac_run' | 'ac_merge_to_main', ToolEntry['handler']> { const fetchImpl = options.fetch ?? fetch; const requestedBy = options.requestedBy ?? 'concierge'; + const controlToken = process.env.RUNFORGE_CONTROL_TOKEN; const request = async (path: string, init: RequestInit = {}): Promise => { const url = `${options.baseUrl.replace(/\/+$/, '')}${path}`; - const response = await fetchImpl(url, init); + const headers: Record = {}; + if (controlToken !== undefined && controlToken !== '') { + headers.Authorization = `Bearer ${controlToken}`; + } + if (init.headers !== undefined) { + const initHeaders = init.headers as Record; + for (const key of Object.keys(initHeaders)) { + const value = initHeaders[key]; + if (value !== undefined) { + headers[key] = typeof value === 'string' ? value : value.join(', '); + } + } + } + const requestInit = Object.keys(headers).length > 0 ? { ...init, headers } : init; + const response = await fetchImpl(url, requestInit); const body = await readResponseBody(response); if (!response.ok) { const message = readErrorMessage(body); diff --git a/packages/daemon/src/control-plane/__acceptance__/control-auth.acceptance.test.ts b/packages/daemon/src/control-plane/__acceptance__/control-auth.acceptance.test.ts new file mode 100644 index 00000000..78ce32c0 --- /dev/null +++ b/packages/daemon/src/control-plane/__acceptance__/control-auth.acceptance.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { isLoopbackHost, assertBindAllowed, checkAuthorization, ControlBindError } from '../control-auth.js'; + +describe('control-plane auth primitives', () => { + describe('assertBindAllowed', () => { + it('allows loopback binds with or without a token', () => { + expect(() => assertBindAllowed('127.0.0.1', undefined)).not.toThrow(); + expect(() => assertBindAllowed('127.0.0.1', 'tok')).not.toThrow(); + }); + + it('requires a non-empty token for non-loopback binds', () => { + expect(() => assertBindAllowed('0.0.0.0', undefined)).toThrow(ControlBindError); + expect(() => assertBindAllowed('0.0.0.0', 'tok')).not.toThrow(); + expect(() => assertBindAllowed('10.0.0.5', '')).toThrow(ControlBindError); + }); + }); + + describe('isLoopbackHost', () => { + it('recognizes only IPv4 127.0.0.0/8 loopback hosts', () => { + expect(isLoopbackHost('127.0.0.1')).toBe(true); + expect(isLoopbackHost('127.1.2.3')).toBe(true); + expect(isLoopbackHost('0.0.0.0')).toBe(false); + expect(isLoopbackHost('192.168.1.1')).toBe(false); + }); + }); + + describe('checkAuthorization', () => { + const token = 'secrettoken'; + + it('returns 401 when the Authorization header is missing', () => { + expect(checkAuthorization(undefined, token)).toMatchObject({ + ok: false, + status: 401, + }); + }); + + it('accepts the exact bearer token', () => { + expect(checkAuthorization('Bearer secrettoken', token)).toEqual({ ok: true }); + }); + + it('returns 403 for a same-length wrong bearer token', () => { + expect(checkAuthorization('Bearer wrongtoken1', token)).toMatchObject({ + ok: false, + status: 403, + }); + }); + + it('returns 403 without throwing for a different-length wrong bearer token', () => { + let result: ReturnType | undefined; + + expect(() => { + result = checkAuthorization('Bearer short', token); + }).not.toThrow(); + + expect(result).toMatchObject({ + ok: false, + status: 403, + }); + }); + + it('returns 403 for non-bearer schemes', () => { + expect(checkAuthorization('Basic secrettoken', token)).toMatchObject({ + ok: false, + status: 403, + }); + }); + }); +}); diff --git a/packages/daemon/src/control-plane/__acceptance__/server-auth.acceptance.test.ts b/packages/daemon/src/control-plane/__acceptance__/server-auth.acceptance.test.ts new file mode 100644 index 00000000..a4cb05f8 --- /dev/null +++ b/packages/daemon/src/control-plane/__acceptance__/server-auth.acceptance.test.ts @@ -0,0 +1,187 @@ +import { Server as HttpServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createControlServer, type ControlHandlers } from '../server.js'; +import { ControlBindError } from '../control-auth.js'; + +const controlToken = 'secrettoken'; +const wrongToken = 'wrongtoken1'; + +let originalControlToken: string | undefined; +let serverRef: HttpServer | undefined; + +const handlers: ControlHandlers = { + getStatus: () => ({ activeRuns: 0, dailyCost: 1.5, paused: false }), + pause: () => {}, + resume: () => {}, + drain: () => {}, + cancelDrain: () => {}, + halt: async () => ({ + halted: true, + parked: [], + terminated: 0, + escalated: 0, + }), + retry: async (issueNumber: number) => ({ + status: 200 as const, + body: { retrying: issueNumber }, + }), +}; + +beforeEach(() => { + originalControlToken = process.env.RUNFORGE_CONTROL_TOKEN; +}); + +afterEach(async () => { + if (serverRef) { + const server = serverRef; + serverRef = undefined; + await closeServer(server); + } + + if (originalControlToken === undefined) { + delete process.env.RUNFORGE_CONTROL_TOKEN; + } else { + process.env.RUNFORGE_CONTROL_TOKEN = originalControlToken; + } + + vi.restoreAllMocks(); +}); + +async function closeServer(server: HttpServer): Promise { + if (!server.listening) return; + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +async function startServer(): Promise<{ port: number }> { + const { server, start } = createControlServer(0, handlers, '127.0.0.1'); + serverRef = server; + + const result = await start(); + if (!result.ok) throw result.error; + + const address = server.address(); + if (typeof address !== 'object' || address === null) { + throw new Error('control server did not expose an ephemeral port'); + } + + return { port: (address as AddressInfo).port }; +} + +function expectNotAuthFailure(status: number): void { + expect([401, 403]).not.toContain(status); +} + +describe('control server bearer enforcement', () => { + it('requires bearer auth on control routes when RUNFORGE_CONTROL_TOKEN is set', async () => { + process.env.RUNFORGE_CONTROL_TOKEN = controlToken; + const { port } = await startServer(); + + const pauseWithoutBearer = await fetch(`http://127.0.0.1:${port}/pause`, { + method: 'POST', + headers: { 'X-Requested-By': 'acceptance-test' }, + }); + expect(pauseWithoutBearer.status).toBe(401); + + const pauseWithWrongBearer = await fetch(`http://127.0.0.1:${port}/pause`, { + method: 'POST', + headers: { + Authorization: `Bearer ${wrongToken}`, + 'X-Requested-By': 'acceptance-test', + }, + }); + expect(pauseWithWrongBearer.status).toBe(403); + + const pauseWithBearer = await fetch(`http://127.0.0.1:${port}/pause`, { + method: 'POST', + headers: { + Authorization: `Bearer ${controlToken}`, + 'X-Requested-By': 'acceptance-test', + }, + }); + expectNotAuthFailure(pauseWithBearer.status); + + const statusWithoutBearer = await fetch(`http://127.0.0.1:${port}/status`); + expect(statusWithoutBearer.status).toBe(401); + + const statusWithBearer = await fetch(`http://127.0.0.1:${port}/status`, { + headers: { Authorization: `Bearer ${controlToken}` }, + }); + expectNotAuthFailure(statusWithBearer.status); + + const healthWithoutBearer = await fetch(`http://127.0.0.1:${port}/health`); + expect(healthWithoutBearer.status).toBe(200); + + const haltWithoutBearer = await fetch(`http://127.0.0.1:${port}/halt`, { + method: 'POST', + headers: { 'X-Requested-By': 'acceptance-test' }, + }); + expect(haltWithoutBearer.status).toBe(401); + + const haltWithBearer = await fetch(`http://127.0.0.1:${port}/halt`, { + method: 'POST', + headers: { + Authorization: `Bearer ${controlToken}`, + 'X-Requested-By': 'acceptance-test', + }, + }); + expectNotAuthFailure(haltWithBearer.status); + }); + + it('keeps legacy loopback access when RUNFORGE_CONTROL_TOKEN is unset', async () => { + delete process.env.RUNFORGE_CONTROL_TOKEN; + const { port } = await startServer(); + + const pause = await fetch(`http://127.0.0.1:${port}/pause`, { + method: 'POST', + headers: { 'X-Requested-By': 'acceptance-test' }, + }); + expectNotAuthFailure(pause.status); + + const status = await fetch(`http://127.0.0.1:${port}/status`); + expectNotAuthFailure(status.status); + }); + + it('retains the X-Requested-By CSRF check after valid bearer auth', async () => { + process.env.RUNFORGE_CONTROL_TOKEN = controlToken; + const { port } = await startServer(); + + const response = await fetch(`http://127.0.0.1:${port}/pause`, { + method: 'POST', + headers: { Authorization: `Bearer ${controlToken}` }, + }); + + expect(response.status).toBe(403); + }); + + it('refuses tokenless non-loopback binds before listening', async () => { + delete process.env.RUNFORGE_CONTROL_TOKEN; + + const listenSpy = vi.spyOn(HttpServer.prototype, 'listen'); + listenSpy.mockImplementation(function (this: HttpServer): HttpServer { + throw new Error('listen should not be reached for tokenless non-loopback bind'); + }); + + let handle: ReturnType | undefined; + let failure: unknown; + + try { + handle = createControlServer(0, handlers, '0.0.0.0'); + serverRef = handle.server; + const result = await handle.start(); + if (!result.ok) failure = result.error; + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(ControlBindError); + expect(listenSpy).not.toHaveBeenCalled(); + expect(handle?.server.listening ?? false).toBe(false); + }); +}); diff --git a/packages/daemon/src/control-plane/cli.test.ts b/packages/daemon/src/control-plane/cli.test.ts index 3c613729..ee92e852 100644 --- a/packages/daemon/src/control-plane/cli.test.ts +++ b/packages/daemon/src/control-plane/cli.test.ts @@ -10,6 +10,7 @@ let serverRef: Server | undefined; // stopServer() the port is no longer listening, which is exactly what the // connection-failure tests need. let port = 0; +let originalControlToken: string | undefined; const handlers = { getStatus: () => ({ activeRuns: 0, paused: false }), @@ -49,11 +50,17 @@ beforeEach(async () => { process.exitCode = undefined; handlers.pause.mockClear(); handlers.resume.mockClear(); + originalControlToken = process.env.RUNFORGE_CONTROL_TOKEN; await startServer(); }); afterEach(async () => { await stopServer(); + if (originalControlToken === undefined) { + delete process.env.RUNFORGE_CONTROL_TOKEN; + } else { + process.env.RUNFORGE_CONTROL_TOKEN = originalControlToken; + } }); describe('createCli', () => { @@ -72,6 +79,14 @@ describe('CLI commands send X-Requested-By header on POST', () => { expect(process.exitCode).toBeUndefined(); }); + it('pause command sends Authorization Bearer when RUNFORGE_CONTROL_TOKEN is set', async () => { + process.env.RUNFORGE_CONTROL_TOKEN = 'clitoken'; + const cli = createCli(); + await cli.parseAsync(['node', 'runforge', 'pause', '-p', String(port)]); + expect(handlers.pause).toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + it('resume command succeeds (not 403)', async () => { const cli = createCli(); await cli.parseAsync(['node', 'runforge', 'resume', '-p', String(port)]); diff --git a/packages/daemon/src/control-plane/cli.ts b/packages/daemon/src/control-plane/cli.ts index 4f75f743..851d7d9d 100644 --- a/packages/daemon/src/control-plane/cli.ts +++ b/packages/daemon/src/control-plane/cli.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { resolveControlToken } from './resolve-control-token.js'; export function createCli(): Command { const program = new Command(); @@ -63,6 +64,8 @@ async function callApi(port: number, method: string, path: string): Promise = {}; if (method === 'POST') headers['X-Requested-By'] = 'cli'; + const token = resolveControlToken(); + if (token !== undefined) headers.Authorization = `Bearer ${token}`; const res = await fetch(`http://127.0.0.1:${port}${path}`, { method, headers }); const body = await res.json(); console.log(JSON.stringify(body, null, 2)); diff --git a/packages/daemon/src/control-plane/control-auth.test.ts b/packages/daemon/src/control-plane/control-auth.test.ts new file mode 100644 index 00000000..49503295 --- /dev/null +++ b/packages/daemon/src/control-plane/control-auth.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + isLoopbackHost, + assertBindAllowed, + checkAuthorization, + ControlBindError, +} from './control-auth.js'; + +describe('isLoopbackHost', () => { + it.each([ + ['127.0.0.1', true], + ['127.1.2.3', true], + ['127.255.255.255', true], + ['0.0.0.0', false], + ['192.168.1.1', false], + ['10.0.0.1', false], + ['::1', false], + ['localhost', false], + ])('isLoopbackHost(%s) === %s', (host, expected) => { + expect(isLoopbackHost(host)).toBe(expected); + }); +}); + +describe('assertBindAllowed', () => { + it.each([ + ['127.0.0.1', undefined, false], + ['127.0.0.1', 'token', false], + ['127.1.2.3', '', false], + ['0.0.0.0', undefined, true], + ['0.0.0.0', '', true], + ['192.168.1.1', 'token', false], + ['10.0.0.5', 'token', false], + ])( + 'assertBindAllowed(%s, %s) throws=%s', + (host, token, shouldThrow) => { + if (shouldThrow) { + expect(() => assertBindAllowed(host, token)).toThrow(ControlBindError); + } else { + expect(() => assertBindAllowed(host, token)).not.toThrow(); + } + }, + ); + + it('includes an actionable message for non-loopback + no token', () => { + expect(() => assertBindAllowed('0.0.0.0', undefined)).toThrow( + /Non-loopback control bind .* requires RUNFORGE_CONTROL_TOKEN/, + ); + }); +}); + +describe('checkAuthorization', () => { + const token = 'secrettoken'; + + it('returns 401 when the header is missing', () => { + expect(checkAuthorization(undefined, token)).toEqual({ + ok: false, + status: 401, + error: 'Authorization header required', + }); + }); + + it('accepts the exact bearer token', () => { + expect(checkAuthorization('Bearer secrettoken', token)).toEqual({ ok: true }); + }); + + it('accepts bearer scheme case-insensitively (RFC 7235)', () => { + expect(checkAuthorization('bearer secrettoken', token)).toEqual({ ok: true }); + expect(checkAuthorization('BEARER secrettoken', token)).toEqual({ ok: true }); + }); + + it('returns 403 for a same-length wrong token', () => { + expect(checkAuthorization('Bearer wrongtoken1', token)).toEqual({ + ok: false, + status: 403, + error: 'Invalid control token', + }); + }); + + it('returns 403 without throwing for a different-length wrong token', () => { + expect(() => { + checkAuthorization('Bearer short', token); + }).not.toThrow(); + expect(checkAuthorization('Bearer short', token)).toEqual({ + ok: false, + status: 403, + error: 'Invalid control token', + }); + }); + + it('returns 403 for non-bearer schemes', () => { + expect(checkAuthorization('Basic secrettoken', token)).toEqual({ + ok: false, + status: 403, + error: 'Invalid control token', + }); + }); + + it('normalizes an array header to its first element', () => { + expect(checkAuthorization(['Bearer secrettoken'], token)).toEqual({ ok: true }); + expect(checkAuthorization(['Bearer wrongtoken1'], token)).toEqual({ + ok: false, + status: 403, + error: 'Invalid control token', + }); + }); +}); diff --git a/packages/daemon/src/control-plane/control-auth.ts b/packages/daemon/src/control-plane/control-auth.ts new file mode 100644 index 00000000..b8fcedd4 --- /dev/null +++ b/packages/daemon/src/control-plane/control-auth.ts @@ -0,0 +1,66 @@ +import { isIP } from 'node:net'; +import { timingSafeEqual } from 'node:crypto'; + +export class ControlBindError extends Error {} + +/** + * IPv4 loopback check for the daemon's host contract. Only 127.0.0.0/8 is + * considered loopback; ::1 and hostnames are not loopback here because the + * daemon binds only IPv4 addresses. + */ +export function isLoopbackHost(host: string): boolean { + if (isIP(host) !== 4) return false; + const parts = host.split('.'); + return parts.length === 4 && parts[0] === '127'; +} + +export function assertBindAllowed(host: string, token: string | undefined): void { + const hasToken = typeof token === 'string' && token !== ''; + if (!isLoopbackHost(host) && !hasToken) { + throw new ControlBindError( + `Non-loopback control bind (${host}) requires RUNFORGE_CONTROL_TOKEN. ` + + `Set the token or bind 127.0.0.1 to start.`, + ); + } +} + +export type AuthResult = { ok: true } | { ok: false; status: 401 | 403; error: string }; + +export function checkAuthorization( + authorizationHeader: string | string[] | undefined, + token: string, +): AuthResult { + const header = Array.isArray(authorizationHeader) + ? authorizationHeader[0] + : authorizationHeader; + + if (header === undefined) { + return { ok: false, status: 401, error: 'Authorization header required' }; + } + + const parts = header.split(' '); + if (parts[0]?.toLowerCase() !== 'bearer' || parts.length !== 2) { + return { ok: false, status: 403, error: 'Invalid control token' }; + } + + const provided = parts[1]; + if (provided === undefined) { + return { ok: false, status: 403, error: 'Invalid control token' }; + } + + const providedBuf = Buffer.from(provided); + const tokenBuf = Buffer.from(token); + + if (providedBuf.length !== tokenBuf.length) { + return { ok: false, status: 403, error: 'Invalid control token' }; + } + + try { + if (timingSafeEqual(providedBuf, tokenBuf)) { + return { ok: true }; + } + return { ok: false, status: 403, error: 'Invalid control token' }; + } catch { + return { ok: false, status: 403, error: 'Invalid control token' }; + } +} diff --git a/packages/daemon/src/control-plane/daemon.test.ts b/packages/daemon/src/control-plane/daemon.test.ts index cc15740e..0bc24329 100644 --- a/packages/daemon/src/control-plane/daemon.test.ts +++ b/packages/daemon/src/control-plane/daemon.test.ts @@ -933,6 +933,54 @@ describe('daemon', () => { } }); + it('returns error when controlHost is non-loopback and RUNFORGE_CONTROL_TOKEN is unset', async () => { + mockLoadConfig.mockResolvedValue( + ok(makeConfig({ controlHost: '0.0.0.0' })), + ); + const originalToken = process.env.RUNFORGE_CONTROL_TOKEN; + delete process.env.RUNFORGE_CONTROL_TOKEN; + + try { + const { startDaemon } = await loadDaemon(); + const result = await startDaemon('config.json'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('RUNFORGE_CONTROL_TOKEN'); + } + expect(mockServerStart).not.toHaveBeenCalled(); + } finally { + if (originalToken === undefined) { + delete process.env.RUNFORGE_CONTROL_TOKEN; + } else { + process.env.RUNFORGE_CONTROL_TOKEN = originalToken; + } + } + }); + + it('warns once when starting in legacy loopback mode without RUNFORGE_CONTROL_TOKEN', async () => { + const originalToken = process.env.RUNFORGE_CONTROL_TOKEN; + delete process.env.RUNFORGE_CONTROL_TOKEN; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const { startDaemon } = await loadDaemon(); + const result = await startDaemon('config.json'); + + expect(result.ok).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('legacy loopback mode'), + ); + } finally { + warnSpy.mockRestore(); + if (originalToken === undefined) { + delete process.env.RUNFORGE_CONTROL_TOKEN; + } else { + process.env.RUNFORGE_CONTROL_TOKEN = originalToken; + } + } + }); + it('returns error when config loading fails', async () => { mockLoadConfig.mockResolvedValue(err(new Error('bad config'))); diff --git a/packages/daemon/src/control-plane/daemon.ts b/packages/daemon/src/control-plane/daemon.ts index 66f02add..8932b2e1 100644 --- a/packages/daemon/src/control-plane/daemon.ts +++ b/packages/daemon/src/control-plane/daemon.ts @@ -25,6 +25,7 @@ import { import { ImplementationCoordinator } from '../implementation/coordinator.js'; import { StateManager } from './state.js'; import { createControlServer } from './server.js'; +import { assertBindAllowed, ControlBindError, isLoopbackHost } from './control-auth.js'; import { createDegradedServer, type DegradedServerHandle, @@ -613,6 +614,33 @@ export async function startDaemon( ); } + // Bind-host startup gate: non-loopback binds require a non-empty control token. + // Loopback + no token is allowed as a legacy mode with a loud warning. + try { + assertBindAllowed(daemonHost, process.env.RUNFORGE_CONTROL_TOKEN); + } catch (e) { + if (e instanceof ControlBindError) { + return err( + new Error( + `${e.message} ` + + `Set RUNFORGE_CONTROL_TOKEN or bind 127.0.0.1 to start the daemon.`, + ), + ); + } + return err(e instanceof Error ? e : new Error(String(e))); + } + + if ( + isLoopbackHost(daemonHost) && + (process.env.RUNFORGE_CONTROL_TOKEN === undefined || + process.env.RUNFORGE_CONTROL_TOKEN === '') + ) { + console.warn( + '[daemon] Starting in legacy loopback mode: control-plane requests are ' + + 'unauthenticated. Set RUNFORGE_CONTROL_TOKEN to secure this deployment.', + ); + } + // Initialize data layer. After data-platform cutover the daemon uses the // project-owned Postgres stores only; missing or retired backends fail fast. try { diff --git a/packages/daemon/src/control-plane/degraded-server.test.ts b/packages/daemon/src/control-plane/degraded-server.test.ts index 09e7a2a7..c73cb195 100644 --- a/packages/daemon/src/control-plane/degraded-server.test.ts +++ b/packages/daemon/src/control-plane/degraded-server.test.ts @@ -1,10 +1,11 @@ import { createServer } from 'net'; import type { AddressInfo } from 'net'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ConfigFetchError } from '../data/config-reader.js'; import { createDegradedServer, type DegradedState } from './degraded-server.js'; +import { ControlBindError } from './control-auth.js'; const HOST = '127.0.0.1'; @@ -18,10 +19,21 @@ const sampleError: ConfigFetchError = { }; let toClose: { close: () => Promise }[] = []; +let originalControlToken: string | undefined; + +beforeEach(() => { + originalControlToken = process.env.RUNFORGE_CONTROL_TOKEN; +}); afterEach(async () => { for (const handle of toClose) await handle.close(); toClose = []; + + if (originalControlToken === undefined) { + delete process.env.RUNFORGE_CONTROL_TOKEN; + } else { + process.env.RUNFORGE_CONTROL_TOKEN = originalControlToken; + } }); // createDegradedServer's handle does not expose the underlying server, so the @@ -172,4 +184,38 @@ describe('createDegradedServer', () => { await expect(handle.close()).resolves.toBeUndefined(); await expect(handle.close()).resolves.toBeUndefined(); }); + + it('requires bearer auth on /status when RUNFORGE_CONTROL_TOKEN is set', async () => { + process.env.RUNFORGE_CONTROL_TOKEN = 'testtoken'; + const { port } = await startServerOnFreePort({ lastConfigError: null }); + + const withoutBearer = await fetch(`http://${HOST}:${port}/status`); + expect(withoutBearer.status).toBe(401); + + const withWrongBearer = await fetch(`http://${HOST}:${port}/status`, { + headers: { Authorization: 'Bearer wrongtoken' }, + }); + expect(withWrongBearer.status).toBe(403); + + const withBearer = await fetch(`http://${HOST}:${port}/status`, { + headers: { Authorization: 'Bearer testtoken' }, + }); + expect(withBearer.status).toBe(200); + }); + + it('/health stays open even when RUNFORGE_CONTROL_TOKEN is set', async () => { + process.env.RUNFORGE_CONTROL_TOKEN = 'testtoken'; + const { port } = await startServerOnFreePort({ lastConfigError: null }); + + const res = await fetch(`http://${HOST}:${port}/health`); + expect(res.status).toBe(200); + }); + + it('refuses tokenless non-loopback binds before listening', async () => { + delete process.env.RUNFORGE_CONTROL_TOKEN; + const port = await freePort(); + expect(() => createDegradedServer(port, '0.0.0.0', () => ({ lastConfigError: null }))).toThrow( + ControlBindError, + ); + }); }); diff --git a/packages/daemon/src/control-plane/degraded-server.ts b/packages/daemon/src/control-plane/degraded-server.ts index 80a568fd..fd10cb6f 100644 --- a/packages/daemon/src/control-plane/degraded-server.ts +++ b/packages/daemon/src/control-plane/degraded-server.ts @@ -4,6 +4,8 @@ import { type ServerResponse, } from 'http'; +import { checkAuthorization, assertBindAllowed } from './control-auth.js'; + import type { ConfigFetchError } from '../data/config-reader.js'; import { err, ok, type Result } from '../lib/result.js'; @@ -27,6 +29,8 @@ export function createDegradedServer( host: string, getState: () => DegradedState, ): { start: () => Promise>; handle: DegradedServerHandle } { + assertBindAllowed(host, process.env.RUNFORGE_CONTROL_TOKEN); + const server = createServer( (req: IncomingMessage, res: ServerResponse) => { const url = new URL(req.url ?? '/', `http://localhost:${port}`); @@ -36,6 +40,15 @@ export function createDegradedServer( if (method === 'GET' && url.pathname === '/health') { json(res, 200, { ok: true, degraded: true, lastConfigError }); } else if (method === 'GET' && url.pathname === '/status') { + const controlToken = process.env.RUNFORGE_CONTROL_TOKEN; + const tokenConfigured = typeof controlToken === 'string' && controlToken !== ''; + if (tokenConfigured) { + const auth = checkAuthorization(req.headers.authorization, controlToken); + if (!auth.ok) { + json(res, auth.status, { error: auth.error }); + return; + } + } json(res, 200, { degraded: true, lastConfigError, diff --git a/packages/daemon/src/control-plane/resolve-control-token.test.ts b/packages/daemon/src/control-plane/resolve-control-token.test.ts new file mode 100644 index 00000000..31521ae9 --- /dev/null +++ b/packages/daemon/src/control-plane/resolve-control-token.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; + +import { resolveControlToken } from './resolve-control-token.js'; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(testDir, '..', '..', '..', '..'); +const envMacPath = resolve(repoRoot, '.env.mac'); + +describe('resolveControlToken', () => { + let originalCwd: string; + let savedEnvMac: string | undefined; + + beforeEach(() => { + originalCwd = process.cwd(); + delete process.env.RUNFORGE_CONTROL_TOKEN; + if (existsSync(envMacPath)) { + savedEnvMac = readFileSync(envMacPath, 'utf-8'); + } + }); + + afterEach(() => { + process.chdir(originalCwd); + delete process.env.RUNFORGE_CONTROL_TOKEN; + if (savedEnvMac !== undefined) { + writeFileSync(envMacPath, savedEnvMac); + } else if (existsSync(envMacPath)) { + unlinkSync(envMacPath); + } + }); + + it('returns undefined when neither env nor .env.mac is set', () => { + expect(resolveControlToken()).toBeUndefined(); + }); + + it('prefers the env variable over .env.mac', () => { + process.env.RUNFORGE_CONTROL_TOKEN = 'env-token'; + writeFileSync(envMacPath, 'RUNFORGE_CONTROL_TOKEN=env-mac-token\n'); + expect(resolveControlToken()).toBe('env-token'); + }); + + it('reads the token from repo-root .env.mac regardless of cwd', () => { + writeFileSync(envMacPath, 'RUNFORGE_CONTROL_TOKEN=repo-root-token\n'); + // Simulate running the CLI from a subdirectory (e.g. pnpm --filter). + process.chdir(resolve(repoRoot, 'packages', 'daemon')); + expect(resolveControlToken()).toBe('repo-root-token'); + }); + + it('ignores empty values in .env.mac', () => { + writeFileSync(envMacPath, 'RUNFORGE_CONTROL_TOKEN=\n'); + expect(resolveControlToken()).toBeUndefined(); + }); + + it('returns the first non-empty RUNFORGE_CONTROL_TOKEN line', () => { + writeFileSync( + envMacPath, + '# comment\nRUNFORGE_CONTROL_TOKEN=first\nRUNFORGE_CONTROL_TOKEN=second\n', + ); + expect(resolveControlToken()).toBe('first'); + }); +}); diff --git a/packages/daemon/src/control-plane/resolve-control-token.ts b/packages/daemon/src/control-plane/resolve-control-token.ts new file mode 100644 index 00000000..73804b3d --- /dev/null +++ b/packages/daemon/src/control-plane/resolve-control-token.ts @@ -0,0 +1,51 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Resolve the RUNFORGE_CONTROL_TOKEN value used by the daemon CLI to call + * the control plane. + * + * Priority: + * 1. `process.env.RUNFORGE_CONTROL_TOKEN` if set and non-empty. + * 2. The `RUNFORGE_CONTROL_TOKEN` entry in the repo-root `.env.mac` file. + * 3. `undefined` (the request goes tokenless; loopback /health still works). + * + * The repo root is located by walking up from this module file until we find a + * `pnpm-workspace.yaml` or `.git` marker. This keeps CLI calls working no + * matter what the current working directory is (e.g. `pnpm --filter` runs from + * `packages/daemon`). + */ +export function resolveControlToken(): string | undefined { + const envToken = process.env.RUNFORGE_CONTROL_TOKEN; + if (envToken !== undefined && envToken !== '') return envToken; + + try { + const envPath = resolve(resolveRepoRoot(), '.env.mac'); + const contents = readFileSync(envPath, 'utf-8'); + for (const line of contents.split(/\r?\n/)) { + const match = line.match(/^RUNFORGE_CONTROL_TOKEN=(.+)$/); + if (match) { + const value = match[1]?.trim(); + if (value !== undefined && value.length > 0) return value; + } + } + } catch { + // .env.mac missing or unreadable — fine; /health works tokenless either way. + } + return undefined; +} + +function resolveRepoRoot(): string { + let current = dirname(fileURLToPath(import.meta.url)); + while (current !== dirname(current)) { + if ( + existsSync(resolve(current, 'pnpm-workspace.yaml')) || + existsSync(resolve(current, '.git')) + ) { + return current; + } + current = dirname(current); + } + return current; +} diff --git a/packages/daemon/src/control-plane/server.test.ts b/packages/daemon/src/control-plane/server.test.ts index 729048c2..c4a2957f 100644 --- a/packages/daemon/src/control-plane/server.test.ts +++ b/packages/daemon/src/control-plane/server.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { createControlServer, type ControlHandlers } from './server.js'; import { err } from '../lib/result.js'; +import { ControlBindError } from './control-auth.js'; import type { Server } from 'http'; import type { AddressInfo } from 'net'; import * as results from './results.js'; @@ -8,6 +9,11 @@ import { DeploymentRegistry } from './deployment-registry/registry.js'; import type { RiskClass, AutonomyLevel } from './deployment-registry/types.js'; let serverRef: Server | undefined; +let originalControlToken: string | undefined; + +beforeEach(() => { + originalControlToken = process.env.RUNFORGE_CONTROL_TOKEN; +}); // Close a server and await the close, clearing serverRef if it's the one being // closed so afterEach doesn't issue a redundant second close (handle leak / @@ -39,6 +45,12 @@ afterEach(async () => { serverRef = undefined; await new Promise((resolve) => s.close(() => resolve())); } + + if (originalControlToken === undefined) { + delete process.env.RUNFORGE_CONTROL_TOKEN; + } else { + process.env.RUNFORGE_CONTROL_TOKEN = originalControlToken; + } }); const handlers = { @@ -122,6 +134,68 @@ describe('ControlServer', () => { expect(body.paused).toBe(true); }); + describe('bearer token enforcement', () => { + const controlToken = 'testtoken'; + + it('requires bearer auth on control routes when token is set', async () => { + process.env.RUNFORGE_CONTROL_TOKEN = controlToken; + const { port } = await startServer(); + + const pauseWithoutBearer = await fetch(`http://127.0.0.1:${port}/pause`, { + method: 'POST', + headers: { 'X-Requested-By': 'test' }, + }); + expect(pauseWithoutBearer.status).toBe(401); + + const pauseWithWrongBearer = await fetch(`http://127.0.0.1:${port}/pause`, { + method: 'POST', + headers: { Authorization: 'Bearer wrongtoken', 'X-Requested-By': 'test' }, + }); + expect(pauseWithWrongBearer.status).toBe(403); + + const pauseWithBearer = await fetch(`http://127.0.0.1:${port}/pause`, { + method: 'POST', + headers: { Authorization: `Bearer ${controlToken}`, 'X-Requested-By': 'test' }, + }); + expect(pauseWithBearer.status).toBe(200); + + const statusWithoutBearer = await fetch(`http://127.0.0.1:${port}/status`); + expect(statusWithoutBearer.status).toBe(401); + + const statusWithBearer = await fetch(`http://127.0.0.1:${port}/status`, { + headers: { Authorization: `Bearer ${controlToken}` }, + }); + expect(statusWithBearer.status).toBe(200); + + const healthWithoutBearer = await fetch(`http://127.0.0.1:${port}/health`); + expect(healthWithoutBearer.status).toBe(200); + }); + + it('keeps legacy loopback access when token is unset', async () => { + delete process.env.RUNFORGE_CONTROL_TOKEN; + const { port } = await startServer(); + + const pause = await fetch(`http://127.0.0.1:${port}/pause`, { + method: 'POST', + headers: { 'X-Requested-By': 'test' }, + }); + expect(pause.status).toBe(200); + + const status = await fetch(`http://127.0.0.1:${port}/status`); + expect(status.status).toBe(200); + }); + + it('retains X-Requested-By CSRF check after valid bearer auth', async () => { + process.env.RUNFORGE_CONTROL_TOKEN = controlToken; + const { port } = await startServer(); + const res = await fetch(`http://127.0.0.1:${port}/pause`, { + method: 'POST', + headers: { Authorization: `Bearer ${controlToken}` }, + }); + expect(res.status).toBe(403); + }); + }); + it('GET /decisions/:id decodes a URL-encoded decision id before lookup (codex)', async () => { let receivedId: string | undefined; const { port } = await startServer({ @@ -496,9 +570,11 @@ describe('ControlServer', () => { } }); - it('binds to custom host when provided (Docker compatibility)', async () => { + it('binds to custom host with a token (Docker compatibility)', async () => { // Regression test for #147: daemon must accept a configurable bind host - // so it can bind 0.0.0.0 in Docker for cross-container access + // so it can bind 0.0.0.0 in Docker for cross-container access. With a token + // configured, non-loopback binds are allowed. + process.env.RUNFORGE_CONTROL_TOKEN = 'testtoken'; const { server, start } = createControlServer(0, handlers, '0.0.0.0'); const result = await start(); expect(result.ok).toBe(true); @@ -516,6 +592,14 @@ describe('ControlServer', () => { } }); + it('refuses tokenless non-loopback binds before listening', async () => { + delete process.env.RUNFORGE_CONTROL_TOKEN; + + expect(() => createControlServer(0, handlers, '0.0.0.0')).toThrow( + ControlBindError, + ); + }); + it('defaults to 127.0.0.1 when no host provided (secure default)', async () => { // Regression test for #147: without explicit host, server should bind loopback only const { server, start } = createControlServer(0, handlers); diff --git a/packages/daemon/src/control-plane/server.ts b/packages/daemon/src/control-plane/server.ts index 15f2503f..715e8cd9 100644 --- a/packages/daemon/src/control-plane/server.ts +++ b/packages/daemon/src/control-plane/server.ts @@ -1,6 +1,22 @@ import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'; import { ok, err, type Result } from '../lib/result.js'; +import { checkAuthorization, assertBindAllowed } from './control-auth.js'; import { getDashboardHtml } from './dashboard.js'; + +let lastLegacyWarningAt = 0; +const LEGACY_WARNING_INTERVAL_MS = 60_000; + +function maybeWarnLegacyLoopback(): void { + const now = Date.now(); + if (now - lastLegacyWarningAt > LEGACY_WARNING_INTERVAL_MS) { + lastLegacyWarningAt = now; + console.warn( + '[control-plane] Unauthenticated control-plane access on loopback is deprecated. ' + + 'Set RUNFORGE_CONTROL_TOKEN to secure this deployment.', + ); + } +} + import { readResults } from './results.js'; import type { ProposeResult } from './release/executor.js'; import type { PreviewResult } from './release/types.js'; @@ -87,9 +103,26 @@ export function createControlServer( handlers: ControlHandlers, host: string = '127.0.0.1', ): { server: Server; start: () => Promise> } { + assertBindAllowed(host, process.env.RUNFORGE_CONTROL_TOKEN); + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const url = new URL(req.url ?? '/', `http://localhost:${port}`); const method = req.method ?? 'GET'; + const isHealth = method === 'GET' && url.pathname === '/health'; + + // Bearer boundary on every route except GET /health. In legacy loopback mode + // (token unset) allow the request but warn about deprecation. + const controlToken = process.env.RUNFORGE_CONTROL_TOKEN; + const tokenConfigured = typeof controlToken === 'string' && controlToken !== ''; + if (!isHealth && tokenConfigured) { + const auth = checkAuthorization(req.headers.authorization, controlToken); + if (!auth.ok) { + json(res, auth.status, { error: auth.error }); + return; + } + } else if (!isHealth && !tokenConfigured) { + maybeWarnLegacyLoopback(); + } // CSRF protection: require a custom header on mutating (POST/PUT) requests. // Browsers enforce CORS preflight for requests with custom headers, @@ -145,25 +178,6 @@ export function createControlServer( handlers.pause(); json(res, 200, { paused: true }); } else if (method === 'POST' && url.pathname === '/halt') { - // P0.5 emergency halt. When RUNFORGE_CONTROL_TOKEN is set, require a - // valid Bearer token in addition to the CSRF header. Halting is the safe - // direction, so the token being UNSET does not block the emergency stop - // locally — but production deployments should always configure it. - const controlToken = process.env.RUNFORGE_CONTROL_TOKEN; - if (controlToken !== undefined && controlToken !== '') { - const authHeader = Array.isArray(req.headers.authorization) - ? req.headers.authorization[0] - : req.headers.authorization; - if (authHeader === undefined) { - json(res, 401, { error: 'Authorization header required' }); - return; - } - const parts = authHeader.split(' '); - if (parts[0]?.toLowerCase() !== 'bearer' || parts[1] !== controlToken) { - json(res, 403, { error: 'Invalid control token' }); - return; - } - } if (handlers.halt) { try { const result = await handlers.halt(); diff --git a/packages/daemon/src/main.test.ts b/packages/daemon/src/main.test.ts index c0db1b7d..19b86c4d 100644 --- a/packages/daemon/src/main.test.ts +++ b/packages/daemon/src/main.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { createControlServer } from './control-plane/server.js'; import { formatStartupError } from './main.js'; import type { Server } from 'http'; @@ -9,6 +9,7 @@ import type { AddressInfo } from 'net'; // We test the actual fetch behavior that main.ts uses, not the CLI wrapper. let serverRef: Server | undefined; +let originalControlToken: string | undefined; const handlers = { getStatus: () => ({ activeRuns: 0, paused: false }), @@ -24,12 +25,21 @@ const handlers = { ), }; +beforeEach(() => { + originalControlToken = process.env.RUNFORGE_CONTROL_TOKEN; +}); + afterEach(async () => { if (serverRef) { const s = serverRef; serverRef = undefined; await new Promise((resolve) => s.close(() => resolve())); } + if (originalControlToken === undefined) { + delete process.env.RUNFORGE_CONTROL_TOKEN; + } else { + process.env.RUNFORGE_CONTROL_TOKEN = originalControlToken; + } }); describe('formatStartupError', () => { @@ -97,4 +107,19 @@ describe('main.ts callApi X-Requested-By header (#148)', () => { const res = await fetch(`http://127.0.0.1:${port}/pause`, { method: 'POST' }); expect(res.status).toBe(403); }); + + it('POST forwards RUNFORGE_CONTROL_TOKEN as Authorization Bearer when set', async () => { + process.env.RUNFORGE_CONTROL_TOKEN = 'clitoken'; + const { server, start } = createControlServer(0, handlers); + serverRef = server; + await start(); + const port = (server.address() as AddressInfo).port; + + const headers: Record = {}; + headers['X-Requested-By'] = 'cli'; + headers.Authorization = 'Bearer clitoken'; + const res = await fetch(`http://127.0.0.1:${port}/pause`, { method: 'POST', headers }); + expect(res.status).toBe(200); + expect(handlers.pause).toHaveBeenCalled(); + }); }); diff --git a/packages/daemon/src/main.ts b/packages/daemon/src/main.ts index dc03af06..36aac2c1 100644 --- a/packages/daemon/src/main.ts +++ b/packages/daemon/src/main.ts @@ -4,6 +4,7 @@ import { Command } from 'commander'; import { config as loadDotenv } from 'dotenv'; import { startDaemon } from './control-plane/daemon.js'; import { processSingleIssue } from './control-plane/process-single.js'; +import { resolveControlToken } from './control-plane/resolve-control-token.js'; const program = new Command(); program @@ -140,6 +141,8 @@ async function callApi(port: number, method: string, path: string): Promise = {}; if (method === 'POST') headers['X-Requested-By'] = 'cli'; + const token = resolveControlToken(); + if (token !== undefined) headers.Authorization = `Bearer ${token}`; const res = await fetch(`http://127.0.0.1:${port}${path}`, { method, headers }); let body: unknown; try { diff --git a/packages/dashboard/actions/github-connections.ts b/packages/dashboard/actions/github-connections.ts index 31ce6b76..a13ba059 100644 --- a/packages/dashboard/actions/github-connections.ts +++ b/packages/dashboard/actions/github-connections.ts @@ -2,7 +2,7 @@ import { revalidatePath } from 'next/cache'; import { requireDashboardAdmin } from '@/lib/auth/require-session'; import { getDashboardStores } from '@/lib/data/stores'; -import { daemonFetch } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError } from '@/lib/daemon-fetch'; const SAFE_PATTERN = /^[a-zA-Z0-9._-]+$/; @@ -10,7 +10,11 @@ function notifyDaemonReload() { daemonFetch('/repos/reload', { method: 'POST', signal: AbortSignal.timeout(3000), - }).catch(() => {}); + }).catch((e) => { + if (e instanceof DaemonAuthError) { + console.error('[github-connections] daemon reload failed:', e.message); + } + }); } export async function removeConnection(connectionId: string) { diff --git a/packages/dashboard/actions/repos.ts b/packages/dashboard/actions/repos.ts index fa5e20dc..d32814a1 100644 --- a/packages/dashboard/actions/repos.ts +++ b/packages/dashboard/actions/repos.ts @@ -3,7 +3,7 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { requireDashboardAdmin } from '@/lib/auth/require-session'; import { getDashboardStores } from '@/lib/data/stores'; -import { daemonFetch } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError } from '@/lib/daemon-fetch'; const SAFE_PATTERN = /^[a-zA-Z0-9._-]+$/; @@ -41,7 +41,11 @@ function notifyDaemonReload() { daemonFetch('/repos/reload', { method: 'POST', signal: AbortSignal.timeout(3000), - }).catch(() => {}); + }).catch((e) => { + if (e instanceof DaemonAuthError) { + console.error('[repos] daemon reload failed:', e.message); + } + }); } function readBranch(value: FormDataEntryValue | null, fallback: string) { diff --git a/packages/dashboard/app/(dashboard)/metrics/page.tsx b/packages/dashboard/app/(dashboard)/metrics/page.tsx index 30f971a2..ec0ccba0 100644 --- a/packages/dashboard/app/(dashboard)/metrics/page.tsx +++ b/packages/dashboard/app/(dashboard)/metrics/page.tsx @@ -1,6 +1,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { EscalationTrendChart } from '@/components/metrics/escalation-trend-chart'; -import { daemonFetch } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError } from '@/lib/daemon-fetch'; export const dynamic = 'force-dynamic'; @@ -27,7 +27,8 @@ async function readEscalationMetrics(): Promise { unavailable: json.unavailable === true, }; } catch (e) { - console.error('[metrics] failed to load escalation metrics:', e); + const message = e instanceof DaemonAuthError ? e.message : String(e); + console.error('[metrics] failed to load escalation metrics:', message); return { weeks: [], unavailable: true }; } } diff --git a/packages/dashboard/app/(dashboard)/page.tsx b/packages/dashboard/app/(dashboard)/page.tsx index d5a25255..abfdf08f 100644 --- a/packages/dashboard/app/(dashboard)/page.tsx +++ b/packages/dashboard/app/(dashboard)/page.tsx @@ -2,6 +2,7 @@ import { PageError } from '@/components/page-error'; import { StatsCards } from '@/components/stats-cards'; import { RunTable } from '@/components/run-table'; import { getDashboardStores } from '@/lib/data/stores'; +import { daemonFetch, DaemonAuthError } from '@/lib/daemon-fetch'; export const dynamic = 'force-dynamic'; @@ -17,17 +18,17 @@ export default async function HomePage() { } let daemonStatus: 'running' | 'paused' | 'offline' = 'offline'; - if (!process.env.DAEMON_URL) { - console.error('[daemon-status] DAEMON_URL is not configured'); - } else { - try { - const res = await fetch(`${process.env.DAEMON_URL}/status`, { - cache: 'no-store', - signal: AbortSignal.timeout(3000), - }); - const json = await res.json().catch(() => null); - daemonStatus = json?.state ?? (res.ok ? 'running' : 'offline'); - } catch (err) { + try { + const res = await daemonFetch('/status', { + cache: 'no-store', + signal: AbortSignal.timeout(3000), + }); + const json = await res.json().catch(() => null); + daemonStatus = json?.state ?? (res.ok ? 'running' : 'offline'); + } catch (err) { + if (err instanceof DaemonAuthError) { + console.error('[daemon-status] auth error:', err.message); + } else { console.error('[daemon-status] unreachable:', err instanceof Error ? err.message : err); } } diff --git a/packages/dashboard/app/(dashboard)/steering/page.test.tsx b/packages/dashboard/app/(dashboard)/steering/page.test.tsx index e3697943..b78fab66 100644 --- a/packages/dashboard/app/(dashboard)/steering/page.test.tsx +++ b/packages/dashboard/app/(dashboard)/steering/page.test.tsx @@ -10,6 +10,7 @@ vi.mock('@/actions/briefing', () => ({ vi.mock('@/lib/daemon-fetch', () => ({ daemonFetch: mocks.daemonFetch, DaemonConfigError: class DaemonConfigError extends Error {}, + DaemonAuthError: class DaemonAuthError extends Error {}, })); vi.mock('next/navigation', () => ({ diff --git a/packages/dashboard/app/(dashboard)/steering/page.tsx b/packages/dashboard/app/(dashboard)/steering/page.tsx index 76acdc1a..e56da72f 100644 --- a/packages/dashboard/app/(dashboard)/steering/page.tsx +++ b/packages/dashboard/app/(dashboard)/steering/page.tsx @@ -7,7 +7,7 @@ import { } from '@/components/decisions/decision-inbox'; import { DaemonControls } from '@/components/steering/daemon-controls'; import { isDashboardAdmin } from '@/lib/auth/require-session'; -import { daemonFetch } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError } from '@/lib/daemon-fetch'; export const dynamic = 'force-dynamic'; @@ -42,10 +42,8 @@ async function readDecisionInbox(): Promise<{ : ((json.items ?? []) as RankedListItem[]); return { items, unavailable: false }; } catch (err) { - console.error( - '[decisions-inbox] unreachable:', - err instanceof Error ? err.message : err, - ); + const message = err instanceof DaemonAuthError ? err.message : (err instanceof Error ? err.message : String(err)); + console.error('[decisions-inbox] unreachable:', message); return { items: [], unavailable: true }; } } diff --git a/packages/dashboard/app/api/daemon/daemon-routes.test.ts b/packages/dashboard/app/api/daemon/daemon-routes.test.ts index 91460354..dcb05c1c 100644 --- a/packages/dashboard/app/api/daemon/daemon-routes.test.ts +++ b/packages/dashboard/app/api/daemon/daemon-routes.test.ts @@ -117,6 +117,19 @@ describe.each(adminRoutes)('POST /api/daemon/$name', ({ path, daemonPath }) => { ); }); + it('returns 500 when daemon rejects with 401/403 (auth error)', async () => { + fetchMock.mockResolvedValueOnce( + new Response('unauthorized', { status: 401 }), + ); + const { POST } = await import(path); + + const res = await POST(); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/RUNFORGE_CONTROL_TOKEN/); + }); + it('returns 503 when daemon is unreachable', async () => { fetchMock.mockRejectedValueOnce(new Error('Connection refused')); const { POST } = await import(path); @@ -279,6 +292,19 @@ describe('GET /api/daemon/status', () => { expect(callArgs).not.toHaveProperty('next'); }); + it('returns 500 when daemon rejects with 401/403 (auth error)', async () => { + fetchMock.mockResolvedValueOnce( + new Response('unauthorized', { status: 401 }), + ); + const { GET } = await import('./status/route.js'); + + const res = await GET(); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/RUNFORGE_CONTROL_TOKEN/); + }); + it('returns 503 with fallback body when daemon is unreachable', async () => { fetchMock.mockRejectedValueOnce(new Error('Connection refused')); const { GET } = await import('./status/route.js'); diff --git a/packages/dashboard/app/api/daemon/halt/route.ts b/packages/dashboard/app/api/daemon/halt/route.ts index f52b0173..e3584ce5 100644 --- a/packages/dashboard/app/api/daemon/halt/route.ts +++ b/packages/dashboard/app/api/daemon/halt/route.ts @@ -17,7 +17,7 @@ import { getDashboardAuthError, requireDashboardAdmin, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; export async function POST() { try { @@ -28,14 +28,7 @@ export async function POST() { } try { - const token = process.env.RUNFORGE_CONTROL_TOKEN; - const res = await daemonFetch('/halt', { - method: 'POST', - headers: - token !== undefined && token !== '' - ? { Authorization: `Bearer ${token}` } - : undefined, - }); + const res = await daemonFetch('/halt', { method: 'POST' }); let json: unknown; try { json = await res.json(); @@ -47,7 +40,7 @@ export async function POST() { } return NextResponse.json(json, { status: res.status }); } catch (e) { - if (e instanceof DaemonConfigError) { + if (e instanceof DaemonConfigError || e instanceof DaemonAuthError) { return NextResponse.json({ error: e.message }, { status: 500 }); } return NextResponse.json({ error: 'Daemon unreachable' }, { status: 503 }); diff --git a/packages/dashboard/app/api/daemon/issues/scan/route.ts b/packages/dashboard/app/api/daemon/issues/scan/route.ts index 3f001169..c4b63c52 100644 --- a/packages/dashboard/app/api/daemon/issues/scan/route.ts +++ b/packages/dashboard/app/api/daemon/issues/scan/route.ts @@ -3,7 +3,7 @@ import { getDashboardAuthError, requireDashboardAdmin, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; export async function POST() { try { @@ -26,7 +26,7 @@ export async function POST() { } return NextResponse.json(json, { status: res.status }); } catch (e) { - if (e instanceof DaemonConfigError) { + if (e instanceof DaemonConfigError || e instanceof DaemonAuthError) { return NextResponse.json({ error: e.message }, { status: 500 }); } return NextResponse.json({ error: 'Daemon unreachable' }, { status: 503 }); diff --git a/packages/dashboard/app/api/daemon/p3-halt-proxy.gate.test.ts b/packages/dashboard/app/api/daemon/p3-halt-proxy.gate.test.ts index 23755cca..e742b442 100644 --- a/packages/dashboard/app/api/daemon/p3-halt-proxy.gate.test.ts +++ b/packages/dashboard/app/api/daemon/p3-halt-proxy.gate.test.ts @@ -141,7 +141,7 @@ describe('POST /api/daemon/halt', () => { ); }); - it('forwards Authorization: Bearer from RUNFORGE_CONTROL_TOKEN when configured', async () => { + it('forwards Authorization: Bearer from RUNFORGE_CONTROL_TOKEN via daemonFetch when configured', async () => { vi.stubEnv('RUNFORGE_CONTROL_TOKEN', 'dashboard-secret'); fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ halted: true }), { status: 200 }), @@ -169,4 +169,17 @@ describe('POST /api/daemon/halt', () => { expect(headerValue(init.headers, 'X-Requested-By')).toBe('dashboard'); expect(headerValue(init.headers, 'Authorization')).toBeNull(); }); + + it('maps daemon 401 to a 500 auth-error JSON with an actionable message', async () => { + fetchMock.mockResolvedValueOnce( + new Response('unauthorized', { status: 401 }), + ); + const { POST } = await importHaltRoute(); + + const res = await POST(); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/RUNFORGE_CONTROL_TOKEN/); + }); }); diff --git a/packages/dashboard/app/api/daemon/pause/route.ts b/packages/dashboard/app/api/daemon/pause/route.ts index b86cf191..49e07cc5 100644 --- a/packages/dashboard/app/api/daemon/pause/route.ts +++ b/packages/dashboard/app/api/daemon/pause/route.ts @@ -3,7 +3,7 @@ import { getDashboardAuthError, requireDashboardAdmin, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; export async function POST() { try { @@ -26,7 +26,7 @@ export async function POST() { } return NextResponse.json(json, { status: res.status }); } catch (e) { - if (e instanceof DaemonConfigError) { + if (e instanceof DaemonConfigError || e instanceof DaemonAuthError) { return NextResponse.json({ error: e.message }, { status: 500 }); } return NextResponse.json({ error: 'Daemon unreachable' }, { status: 503 }); diff --git a/packages/dashboard/app/api/daemon/release/route.ts b/packages/dashboard/app/api/daemon/release/route.ts index ac84e1d2..365c96bb 100644 --- a/packages/dashboard/app/api/daemon/release/route.ts +++ b/packages/dashboard/app/api/daemon/release/route.ts @@ -3,7 +3,7 @@ import { getDashboardAuthError, requireDashboardAdmin, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; export async function POST() { try { @@ -26,7 +26,7 @@ export async function POST() { } return NextResponse.json(json, { status: res.status }); } catch (e) { - if (e instanceof DaemonConfigError) { + if (e instanceof DaemonConfigError || e instanceof DaemonAuthError) { return NextResponse.json({ error: e.message }, { status: 500 }); } return NextResponse.json({ error: 'Daemon unreachable' }, { status: 503 }); diff --git a/packages/dashboard/app/api/daemon/remote-control/restart/route.ts b/packages/dashboard/app/api/daemon/remote-control/restart/route.ts index 5c79adc8..6e8221a1 100644 --- a/packages/dashboard/app/api/daemon/remote-control/restart/route.ts +++ b/packages/dashboard/app/api/daemon/remote-control/restart/route.ts @@ -3,7 +3,7 @@ import { getDashboardAuthError, requireDashboardAdmin, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; export async function POST() { try { @@ -26,7 +26,7 @@ export async function POST() { } return NextResponse.json(json, { status: res.status }); } catch (e) { - if (e instanceof DaemonConfigError) { + if (e instanceof DaemonConfigError || e instanceof DaemonAuthError) { return NextResponse.json({ error: e.message }, { status: 500 }); } return NextResponse.json({ error: 'Daemon unreachable' }, { status: 503 }); diff --git a/packages/dashboard/app/api/daemon/repos-reload/route.ts b/packages/dashboard/app/api/daemon/repos-reload/route.ts index 0821efdc..b7ca1f76 100644 --- a/packages/dashboard/app/api/daemon/repos-reload/route.ts +++ b/packages/dashboard/app/api/daemon/repos-reload/route.ts @@ -3,7 +3,7 @@ import { getDashboardAuthError, requireDashboardAdmin, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; export async function POST() { try { @@ -26,7 +26,7 @@ export async function POST() { } return NextResponse.json(json, { status: res.status }); } catch (e) { - if (e instanceof DaemonConfigError) { + if (e instanceof DaemonConfigError || e instanceof DaemonAuthError) { return NextResponse.json({ error: e.message }, { status: 500 }); } return NextResponse.json({ error: 'Daemon unreachable' }, { status: 503 }); diff --git a/packages/dashboard/app/api/daemon/resume/route.ts b/packages/dashboard/app/api/daemon/resume/route.ts index 9ba94917..b4ae4852 100644 --- a/packages/dashboard/app/api/daemon/resume/route.ts +++ b/packages/dashboard/app/api/daemon/resume/route.ts @@ -3,7 +3,7 @@ import { getDashboardAuthError, requireDashboardAdmin, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; export async function POST() { try { @@ -26,7 +26,7 @@ export async function POST() { } return NextResponse.json(json, { status: res.status }); } catch (e) { - if (e instanceof DaemonConfigError) { + if (e instanceof DaemonConfigError || e instanceof DaemonAuthError) { return NextResponse.json({ error: e.message }, { status: 500 }); } return NextResponse.json({ error: 'Daemon unreachable' }, { status: 503 }); diff --git a/packages/dashboard/app/api/daemon/status/route.ts b/packages/dashboard/app/api/daemon/status/route.ts index 98352fee..bd2ac658 100644 --- a/packages/dashboard/app/api/daemon/status/route.ts +++ b/packages/dashboard/app/api/daemon/status/route.ts @@ -3,7 +3,7 @@ import { getDashboardAuthError, requireDashboardUser, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; export async function GET() { try { @@ -29,7 +29,7 @@ export async function GET() { } return NextResponse.json(json, { status: res.status }); } catch (e) { - if (e instanceof DaemonConfigError) { + if (e instanceof DaemonConfigError || e instanceof DaemonAuthError) { return NextResponse.json({ error: e.message }, { status: 500 }); } return NextResponse.json( diff --git a/packages/dashboard/app/api/decisions/[id]/reveal/route.ts b/packages/dashboard/app/api/decisions/[id]/reveal/route.ts index c20e78df..85d76a98 100644 --- a/packages/dashboard/app/api/decisions/[id]/reveal/route.ts +++ b/packages/dashboard/app/api/decisions/[id]/reveal/route.ts @@ -22,7 +22,7 @@ import { requireDashboardAdmin, type DashboardSession, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; interface RouteContext { params: Promise<{ id: string }>; @@ -129,7 +129,7 @@ export async function POST( return NextResponse.json(json, { status: res.status }); } catch (e) { - if (e instanceof DaemonConfigError) { + if (e instanceof DaemonConfigError || e instanceof DaemonAuthError) { return NextResponse.json({ error: e.message }, { status: 500 }); } return NextResponse.json( diff --git a/packages/dashboard/app/api/decisions/[id]/route.ts b/packages/dashboard/app/api/decisions/[id]/route.ts index 5d39aae6..392eab58 100644 --- a/packages/dashboard/app/api/decisions/[id]/route.ts +++ b/packages/dashboard/app/api/decisions/[id]/route.ts @@ -16,7 +16,7 @@ import { getDashboardAuthError, requireDashboardUser, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; interface RouteContext { params: Promise<{ id: string }>; @@ -52,7 +52,7 @@ export async function GET( return NextResponse.json(json, { status: res.status }); } catch (e) { - if (e instanceof DaemonConfigError) { + if (e instanceof DaemonConfigError || e instanceof DaemonAuthError) { return NextResponse.json({ error: e.message }, { status: 500 }); } return NextResponse.json( diff --git a/packages/dashboard/app/api/decisions/answer/route.ts b/packages/dashboard/app/api/decisions/answer/route.ts index 94af96d3..88c88be7 100644 --- a/packages/dashboard/app/api/decisions/answer/route.ts +++ b/packages/dashboard/app/api/decisions/answer/route.ts @@ -23,7 +23,7 @@ import { getDashboardAuthError, requireDashboardAdmin, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; export async function POST(request: NextRequest): Promise { try { @@ -76,7 +76,7 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json(json, { status: res.status }); } catch (e) { - if (e instanceof DaemonConfigError) { + if (e instanceof DaemonConfigError || e instanceof DaemonAuthError) { return NextResponse.json({ error: e.message }, { status: 500 }); } return NextResponse.json( diff --git a/packages/dashboard/app/api/decisions/pending/route.test.ts b/packages/dashboard/app/api/decisions/pending/route.test.ts index e7333b78..7283b1a4 100644 --- a/packages/dashboard/app/api/decisions/pending/route.test.ts +++ b/packages/dashboard/app/api/decisions/pending/route.test.ts @@ -116,4 +116,17 @@ describe('GET /api/decisions/pending', () => { expect(body.unavailable).toBe(true); vi.stubEnv('DAEMON_URL', 'http://localhost:9800'); }); + + it('returns 500 when daemon rejects with 401/403 (DaemonAuthError)', async () => { + fetchMock.mockResolvedValueOnce( + new Response('unauthorized', { status: 401 }), + ); + const { GET } = await import('./route.js'); + + const res = await GET(makeRequest()); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/RUNFORGE_CONTROL_TOKEN/); + }); }); diff --git a/packages/dashboard/app/api/decisions/pending/route.ts b/packages/dashboard/app/api/decisions/pending/route.ts index 229069a6..207530de 100644 --- a/packages/dashboard/app/api/decisions/pending/route.ts +++ b/packages/dashboard/app/api/decisions/pending/route.ts @@ -19,7 +19,7 @@ import { getDashboardAuthError, requireDashboardUser, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; export async function GET(request: NextRequest): Promise { try { @@ -40,6 +40,12 @@ export async function GET(request: NextRequest): Promise { const items = Array.isArray(json) ? json : (json.items ?? []); return NextResponse.json({ items }); } catch (e) { + if (e instanceof DaemonAuthError) { + // Auth failures are actionable: surface the message so operators can + // fix RUNFORGE_CONTROL_TOKEN, instead of collapsing into "unavailable data". + console.error('Daemon control token rejected by decisions/pending:', e.message); + return NextResponse.json({ error: e.message }, { status: 500 }); + } if (e instanceof DaemonConfigError) { return NextResponse.json({ items: [], unavailable: true }); } diff --git a/packages/dashboard/app/api/metrics/escalation/route.test.ts b/packages/dashboard/app/api/metrics/escalation/route.test.ts new file mode 100644 index 00000000..a41edae6 --- /dev/null +++ b/packages/dashboard/app/api/metrics/escalation/route.test.ts @@ -0,0 +1,88 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const authMocks = vi.hoisted(() => ({ + requireDashboardUser: vi.fn(), +})); + +vi.mock('@/lib/auth/require-session', () => ({ + requireDashboardUser: authMocks.requireDashboardUser, + getDashboardAuthError: (error: unknown) => { + const message = error instanceof Error ? error.message : 'Forbidden'; + const status = + 'status' in Object(error) + ? (error as { status: 401 | 403 }).status + : message === 'Unauthorized' + ? 401 + : 403; + return { message, status }; + }, +})); + +vi.stubEnv('DAEMON_URL', 'http://localhost:9800'); + +const originalFetch = globalThis.fetch; +let fetchMock: ReturnType; + +function makeRequest(url = 'http://localhost/api/metrics/escalation') { + return { nextUrl: new URL(url) } as unknown as import('next/server').NextRequest; +} + +beforeEach(() => { + vi.resetModules(); + vi.stubEnv('DAEMON_URL', 'http://localhost:9800'); + authMocks.requireDashboardUser.mockReset(); + authMocks.requireDashboardUser.mockResolvedValue({ + user: { id: 'viewer-1', role: 'viewer' }, + }); + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as typeof fetch; +}); + +afterAll(() => { + globalThis.fetch = originalFetch; + vi.unstubAllEnvs(); +}); + +describe('GET /api/metrics/escalation', () => { + it('proxies to the daemon /metrics/escalation and returns weeks', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ weeks: [{ week: '2026-W01' }] }), { status: 200 }), + ); + const { GET } = await import('./route.js'); + + const res = await GET(makeRequest()); + + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:9800/metrics/escalation', + expect.any(Object), + ); + const body = await res.json(); + expect(body.weeks).toHaveLength(1); + expect(body.unavailable).not.toBe(true); + }); + + it('maps an unreachable daemon to the degraded shape', async () => { + fetchMock.mockRejectedValueOnce(new Error('Connection refused')); + const { GET } = await import('./route.js'); + + const res = await GET(makeRequest()); + + const body = await res.json(); + expect(body.weeks).toEqual([]); + expect(body.unavailable).toBe(true); + }); + + it('returns 500 when daemon rejects with 401/403 (DaemonAuthError)', async () => { + fetchMock.mockResolvedValueOnce( + new Response('unauthorized', { status: 401 }), + ); + const { GET } = await import('./route.js'); + + const res = await GET(makeRequest()); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/RUNFORGE_CONTROL_TOKEN/); + }); +}); diff --git a/packages/dashboard/app/api/metrics/escalation/route.ts b/packages/dashboard/app/api/metrics/escalation/route.ts index c45a7c46..9905d98b 100644 --- a/packages/dashboard/app/api/metrics/escalation/route.ts +++ b/packages/dashboard/app/api/metrics/escalation/route.ts @@ -14,7 +14,7 @@ import { getDashboardAuthError, requireDashboardUser, } from '@/lib/auth/require-session'; -import { daemonFetch, DaemonConfigError } from '@/lib/daemon-fetch'; +import { daemonFetch, DaemonAuthError, DaemonConfigError } from '@/lib/daemon-fetch'; export async function GET(request: NextRequest): Promise { try { @@ -40,6 +40,12 @@ export async function GET(request: NextRequest): Promise { ...(json.unavailable === true ? { unavailable: true } : {}), }); } catch (e) { + if (e instanceof DaemonAuthError) { + // Auth failures are actionable: surface the message so operators can + // fix RUNFORGE_CONTROL_TOKEN, instead of collapsing into "unavailable data". + console.error('Daemon control token rejected by metrics/escalation:', e.message); + return NextResponse.json({ error: e.message }, { status: 500 }); + } if (e instanceof DaemonConfigError) { return NextResponse.json({ weeks: [], unavailable: true }); } diff --git a/packages/dashboard/lib/daemon-fetch.test.ts b/packages/dashboard/lib/daemon-fetch.test.ts index ce203a0d..01f519fb 100644 --- a/packages/dashboard/lib/daemon-fetch.test.ts +++ b/packages/dashboard/lib/daemon-fetch.test.ts @@ -65,11 +65,60 @@ describe('daemonFetch', () => { expect(fetchMock.mock.calls[0][1].signal).toBe(customSignal); }); - it('passes through additional options like cache', async () => { + it('adds Authorization: Bearer from RUNFORGE_CONTROL_TOKEN on GET when set', async () => { vi.stubEnv('DAEMON_URL', 'http://localhost:9800'); + vi.stubEnv('RUNFORGE_CONTROL_TOKEN', 'secret-token'); fetchMock.mockResolvedValueOnce(new Response('ok')); const { daemonFetch } = await import('./daemon-fetch'); - await daemonFetch('/status', { cache: 'no-store' }); - expect(fetchMock.mock.calls[0][1].cache).toBe('no-store'); + await daemonFetch('/status'); + expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ + headers: expect.objectContaining({ 'Authorization': 'Bearer secret-token' }), + })); + }); + + it('adds Authorization: Bearer from RUNFORGE_CONTROL_TOKEN on POST when set', async () => { + vi.stubEnv('DAEMON_URL', 'http://localhost:9800'); + vi.stubEnv('RUNFORGE_CONTROL_TOKEN', 'secret-token'); + fetchMock.mockResolvedValueOnce(new Response('ok')); + const { daemonFetch } = await import('./daemon-fetch'); + await daemonFetch('/pause', { method: 'POST' }); + expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ + headers: expect.objectContaining({ 'Authorization': 'Bearer secret-token' }), + })); + }); + + it('omits Authorization when RUNFORGE_CONTROL_TOKEN is unset', async () => { + vi.stubEnv('DAEMON_URL', 'http://localhost:9800'); + vi.stubEnv('RUNFORGE_CONTROL_TOKEN', ''); + fetchMock.mockResolvedValueOnce(new Response('ok')); + const { daemonFetch } = await import('./daemon-fetch'); + await daemonFetch('/status'); + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.headers).not.toHaveProperty('Authorization'); + }); + + it('does not allow callers to override Authorization', async () => { + vi.stubEnv('DAEMON_URL', 'http://localhost:9800'); + vi.stubEnv('RUNFORGE_CONTROL_TOKEN', 'secret-token'); + fetchMock.mockResolvedValueOnce(new Response('ok')); + const { daemonFetch } = await import('./daemon-fetch'); + await daemonFetch('/status', { headers: { Authorization: 'Bearer attacker-token' } }); + const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record; + expect(headers.Authorization).toBe('Bearer secret-token'); + }); + + it('throws DaemonAuthError when daemon responds with 401', async () => { + vi.stubEnv('DAEMON_URL', 'http://localhost:9800'); + fetchMock.mockResolvedValueOnce(new Response('unauthorized', { status: 401 })); + const { daemonFetch, DaemonAuthError } = await import('./daemon-fetch'); + await expect(daemonFetch('/status')).rejects.toThrow(DaemonAuthError); + }); + + it('throws DaemonAuthError when daemon responds with 403', async () => { + vi.stubEnv('DAEMON_URL', 'http://localhost:9800'); + fetchMock.mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + const { daemonFetch, DaemonAuthError } = await import('./daemon-fetch'); + await expect(daemonFetch('/status')).rejects.toThrow(DaemonAuthError); }); }); + diff --git a/packages/dashboard/lib/daemon-fetch.ts b/packages/dashboard/lib/daemon-fetch.ts index 6771f4b9..3b09ce76 100644 --- a/packages/dashboard/lib/daemon-fetch.ts +++ b/packages/dashboard/lib/daemon-fetch.ts @@ -5,6 +5,13 @@ export class DaemonConfigError extends Error { } } +export class DaemonAuthError extends Error { + constructor() { + super('control token missing or invalid — set RUNFORGE_CONTROL_TOKEN in the dashboard environment'); + this.name = 'DaemonAuthError'; + } +} + export async function daemonFetch( path: string, options?: RequestInit, @@ -13,12 +20,20 @@ export async function daemonFetch( if (!base) throw new DaemonConfigError(); const normalizedBase = base.replace(/\/+$/, ''); - return fetch(`${normalizedBase}${path}`, { + const token = process.env.RUNFORGE_CONTROL_TOKEN; + const res = await fetch(`${normalizedBase}${path}`, { ...options, headers: { 'X-Requested-By': 'dashboard', ...options?.headers, + ...(typeof token === 'string' && token !== '' ? { Authorization: `Bearer ${token}` } : {}), }, signal: options?.signal ?? AbortSignal.timeout(5000), }); + + if (res.status === 401 || res.status === 403) { + throw new DaemonAuthError(); + } + + return res; } diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 0d14feab..c6b857d0 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -20,13 +20,13 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/postcss": "^4.2.2", - "better-auth": "1.6.11", + "better-auth": "1.6.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", "geist": "^1.7.0", "lucide-react": "^0.577.0", - "next": "16.2.0", + "next": "16.2.10", "postcss": "^8.5.8", "postgres": "^3.4.9", "radix-ui": "^1.4.3", @@ -49,7 +49,7 @@ "@types/react-dom": "^19", "@vitejs/plugin-react": "^4.7.0", "eslint": "^9", - "eslint-config-next": "16.2.0", + "eslint-config-next": "16.2.10", "jsdom": "^29.0.0", "typescript": "^5", "vitest": "^4.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96986a90..2988b74c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,15 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + micromatch>picomatch: 2.3.2 + path-to-regexp: 8.4.2 + fast-uri: 3.1.3 + vitest>picomatch: 4.0.5 + vitest>vite: 7.3.6 + jsdom>undici: 7.28.0 + '@dotenvx/dotenvx>picomatch': 4.0.5 + importers: .: @@ -50,10 +59,10 @@ importers: dependencies: '@hono/node-server': specifier: ^1.19.11 - version: 1.19.11(hono@4.12.8) + version: 1.19.11(hono@4.12.28) hono: - specifier: ^4.12.8 - version: 4.12.8 + specifier: ^4.12.28 + version: 4.12.28 devDependencies: '@types/node': specifier: ^25.5.0 @@ -174,8 +183,8 @@ importers: specifier: ^4.2.2 version: 4.2.2 better-auth: - specifier: 1.6.11 - version: 1.6.11(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@20.19.37)(jsdom@29.0.0(@noble/hashes@2.2.0))(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))) + specifier: 1.6.23 + version: 1.6.23(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(next@16.2.10(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@20.19.37)(jsdom@29.0.0(@noble/hashes@2.2.0))(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -187,13 +196,13 @@ importers: version: 0.45.2(@electric-sql/pglite@0.2.17)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9) geist: specifier: ^1.7.0 - version: 1.7.0(next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 1.7.0(next@16.2.10(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.4) next: - specifier: 16.2.0 - version: 16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.2.10 + version: 16.2.10(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) postcss: specifier: ^8.5.8 version: 8.5.8 @@ -251,13 +260,13 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.7.0 - version: 4.7.0(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + version: 4.7.0(vite@7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) eslint: specifier: ^9 version: 9.39.4(jiti@2.6.1) eslint-config-next: - specifier: 16.2.0 - version: 16.2.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 16.2.10 + version: 16.2.10(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) jsdom: specifier: ^29.0.0 version: 29.0.0(@noble/hashes@2.2.0) @@ -266,7 +275,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@20.19.37)(jsdom@29.0.0(@noble/hashes@2.2.0))(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + version: 4.1.0(@types/node@20.19.37)(jsdom@29.0.0(@noble/hashes@2.2.0))(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) packages/db: dependencies: @@ -605,16 +614,16 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@better-auth/core@1.6.11': - resolution: {integrity: sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==} + '@better-auth/core@1.6.23': + resolution: {integrity: sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==} peerDependencies: - '@better-auth/utils': 0.4.0 - '@better-fetch/fetch': 1.1.21 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 '@cloudflare/workers-types': '>=4' '@opentelemetry/api': ^1.9.0 - better-call: 1.3.5 + better-call: 1.3.7 jose: ^6.1.0 - kysely: ^0.28.5 + kysely: ^0.28.5 || ^0.29.0 nanostores: ^1.0.1 peerDependenciesMeta: '@cloudflare/workers-types': @@ -622,47 +631,47 @@ packages: '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.6.11': - resolution: {integrity: sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==} + '@better-auth/drizzle-adapter@1.6.23': + resolution: {integrity: sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==} peerDependencies: - '@better-auth/core': ^1.6.11 - '@better-auth/utils': 0.4.0 + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 drizzle-orm: ^0.45.2 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.6.11': - resolution: {integrity: sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==} + '@better-auth/kysely-adapter@1.6.23': + resolution: {integrity: sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==} peerDependencies: - '@better-auth/core': ^1.6.11 - '@better-auth/utils': 0.4.0 - kysely: ^0.28.17 + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.6.11': - resolution: {integrity: sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==} + '@better-auth/memory-adapter@1.6.23': + resolution: {integrity: sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==} peerDependencies: - '@better-auth/core': ^1.6.11 - '@better-auth/utils': 0.4.0 + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.11': - resolution: {integrity: sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==} + '@better-auth/mongo-adapter@1.6.23': + resolution: {integrity: sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==} peerDependencies: - '@better-auth/core': ^1.6.11 - '@better-auth/utils': 0.4.0 + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/prisma-adapter@1.6.11': - resolution: {integrity: sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==} + '@better-auth/prisma-adapter@1.6.23': + resolution: {integrity: sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==} peerDependencies: - '@better-auth/core': ^1.6.11 - '@better-auth/utils': 0.4.0 + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: @@ -671,18 +680,18 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.6.11': - resolution: {integrity: sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==} + '@better-auth/telemetry@1.6.23': + resolution: {integrity: sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==} peerDependencies: - '@better-auth/core': ^1.6.11 - '@better-auth/utils': 0.4.0 - '@better-fetch/fetch': 1.1.21 + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 - '@better-auth/utils@0.4.0': - resolution: {integrity: sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==} + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} - '@better-fetch/fetch@1.1.21': - resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@better-fetch/fetch@1.3.1': + resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} @@ -1649,60 +1658,60 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@16.2.0': - resolution: {integrity: sha512-OZIbODWWAi0epQRCRjNe1VO45LOFBzgiyqmTLzIqWq6u1wrxKnAyz1HH6tgY/Mc81YzIjRPoYsPAEr4QV4l9TA==} + '@next/env@16.2.10': + resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} - '@next/eslint-plugin-next@16.2.0': - resolution: {integrity: sha512-3D3pEMcGKfENC9Pzlkr67GOm+205+5hRdYPZvHuNIy5sr9k0ybSU8g+sxOO/R/RLEh/gWZ3UlY+5LmEyZ1xgXQ==} + '@next/eslint-plugin-next@16.2.10': + resolution: {integrity: sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==} - '@next/swc-darwin-arm64@16.2.0': - resolution: {integrity: sha512-/JZsqKzKt01IFoiLLAzlNqys7qk2F3JkcUhj50zuRhKDQkZNOz9E5N6wAQWprXdsvjRP4lTFj+/+36NSv5AwhQ==} + '@next/swc-darwin-arm64@16.2.10': + resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.0': - resolution: {integrity: sha512-/hV8erWq4SNlVgglUiW5UmQ5Hwy5EW/AbbXlJCn6zkfKxTy/E/U3V8U1Ocm2YCTUoFgQdoMxRyRMOW5jYy4ygg==} + '@next/swc-darwin-x64@16.2.10': + resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.0': - resolution: {integrity: sha512-GkjL/Q7MWOwqWR9zoxu1TIHzkOI2l2BHCf7FzeQG87zPgs+6WDh+oC9Sw9ARuuL/FUk6JNCgKRkA6rEQYadUaw==} + '@next/swc-linux-arm64-gnu@16.2.10': + resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.0': - resolution: {integrity: sha512-1ffhC6KY5qWLg5miMlKJp3dZbXelEfjuXt1qcp5WzSCQy36CV3y+JT7OC1WSFKizGQCDOcQbfkH/IjZP3cdRNA==} + '@next/swc-linux-arm64-musl@16.2.10': + resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.0': - resolution: {integrity: sha512-FmbDcZQ8yJRq93EJSL6xaE0KK/Rslraf8fj1uViGxg7K4CKBCRYSubILJPEhjSgZurpcPQq12QNOJQ0DRJl6Hg==} + '@next/swc-linux-x64-gnu@16.2.10': + resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.0': - resolution: {integrity: sha512-HzjIHVkmGAwRbh/vzvoBWWEbb8BBZPxBvVbDQDvzHSf3D8RP/4vjw7MNLDXFF9Q1WEzeQyEj2zdxBtVAHu5Oyw==} + '@next/swc-linux-x64-musl@16.2.10': + resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.0': - resolution: {integrity: sha512-UMiFNQf5H7+1ZsZPxEsA064WEuFbRNq/kEXyepbCnSErp4f5iut75dBA8UeerFIG3vDaQNOfCpevnERPp2V+nA==} + '@next/swc-win32-arm64-msvc@16.2.10': + resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.0': - resolution: {integrity: sha512-DRrNJKW+/eimrZgdhVN1uvkN1OI4j6Lpefwr44jKQ0YQzztlmOBUUzHuV5GxOMPK3nmodAYElUVCY8ZXo/IWeA==} + '@next/swc-win32-x64-msvc@16.2.10': + resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3296,8 +3305,8 @@ packages: before-after-hook@3.0.2: resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} - better-auth@1.6.11: - resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==} + better-auth@1.6.23: + resolution: {integrity: sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -3358,8 +3367,8 @@ packages: vue: optional: true - better-call@1.3.5: - resolution: {integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==} + better-call@1.3.7: + resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: @@ -3964,8 +3973,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@16.2.0: - resolution: {integrity: sha512-LlVJrWnjIkgQRECjIOELyAtrWFqzn326ARS5ap7swc1YKL4wkry6/gszn6wi5ZDWKxKe7fanxArvhqMoAzbL7w==} + eslint-config-next@16.2.10: + resolution: {integrity: sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -4157,8 +4166,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4383,8 +4392,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.8: - resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + hono@4.12.28: + resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} engines: {node: '>=16.9.0'} html-encoding-sniffer@6.0.0: @@ -4975,8 +4984,8 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - next@16.2.0: - resolution: {integrity: sha512-NLBVrJy1pbV1Yn00L5sU4vFyAHt5XuSjzrNyFnxo6Com0M0KrL6hHM5B99dbqXb2bE9pm4Ow3Zl1xp6HVY9edQ==} + next@16.2.10: + resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5142,11 +5151,8 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -5158,12 +5164,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} pkce-challenge@5.0.1: @@ -5863,8 +5869,8 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@7.24.4: - resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} unicorn-magic@0.3.0: @@ -5941,8 +5947,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -6023,7 +6029,7 @@ packages: '@vitest/ui': 4.1.0 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: 7.3.6 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -6395,58 +6401,58 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0)': + '@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: - '@better-auth/utils': 0.4.0 - '@better-fetch/fetch': 1.1.21 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 '@opentelemetry/semantic-conventions': 1.41.1 '@standard-schema/spec': 1.1.0 - better-call: 1.3.5(zod@4.3.6) + better-call: 1.3.7(zod@4.3.6) jose: 6.2.2 kysely: 0.28.17 nanostores: 1.3.0 zod: 4.3.6 - '@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))': + '@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))': dependencies: - '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/utils': 0.4.0 + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 optionalDependencies: drizzle-orm: 0.45.2(@electric-sql/pglite@0.2.17)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9) - '@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': + '@better-auth/kysely-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.28.17)': dependencies: - '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/utils': 0.4.0 + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.28.17 - '@better-auth/memory-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/memory-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/utils': 0.4.0 + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/mongo-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/utils': 0.4.0 + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/prisma-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/utils': 0.4.0 + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + '@better-auth/telemetry@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/utils': 0.4.0 - '@better-fetch/fetch': 1.1.21 + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 - '@better-auth/utils@0.4.0': + '@better-auth/utils@0.4.2': dependencies: '@noble/hashes': 2.2.0 - '@better-fetch/fetch@1.1.21': {} + '@better-fetch/fetch@1.3.1': {} '@bramus/specificity@2.4.2': dependencies: @@ -6482,10 +6488,10 @@ snapshots: dotenv: 17.3.1 eciesjs: 0.4.18 execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.5) ignore: 5.3.2 object-treeify: 1.1.33 - picomatch: 4.0.3 + picomatch: 4.0.5 which: 4.0.0 '@drizzle-team/brocli@0.10.2': {} @@ -6880,9 +6886,9 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@hono/node-server@1.19.11(hono@4.12.8)': + '@hono/node-server@1.19.11(hono@4.12.28)': dependencies: - hono: 4.12.8 + hono: 4.12.28 '@hookform/resolvers@5.2.2(react-hook-form@7.71.2(react@19.2.4))': dependencies: @@ -7073,7 +7079,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.11(hono@4.12.8) + '@hono/node-server': 1.19.11(hono@4.12.28) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -7083,7 +7089,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.8 + hono: 4.12.28 jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -7095,7 +7101,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': dependencies: - '@hono/node-server': 1.19.11(hono@4.12.8) + '@hono/node-server': 1.19.11(hono@4.12.28) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -7105,7 +7111,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.8 + hono: 4.12.28 jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -7131,34 +7137,34 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.2.0': {} + '@next/env@16.2.10': {} - '@next/eslint-plugin-next@16.2.0': + '@next/eslint-plugin-next@16.2.10': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.2.0': + '@next/swc-darwin-arm64@16.2.10': optional: true - '@next/swc-darwin-x64@16.2.0': + '@next/swc-darwin-x64@16.2.10': optional: true - '@next/swc-linux-arm64-gnu@16.2.0': + '@next/swc-linux-arm64-gnu@16.2.10': optional: true - '@next/swc-linux-arm64-musl@16.2.0': + '@next/swc-linux-arm64-musl@16.2.10': optional: true - '@next/swc-linux-x64-gnu@16.2.0': + '@next/swc-linux-x64-gnu@16.2.10': optional: true - '@next/swc-linux-x64-musl@16.2.0': + '@next/swc-linux-x64-musl@16.2.10': optional: true - '@next/swc-win32-arm64-msvc@16.2.0': + '@next/swc-win32-arm64-msvc@16.2.10': optional: true - '@next/swc-win32-x64-msvc@16.2.0': + '@next/swc-win32-x64-msvc@16.2.10': optional: true '@noble/ciphers@1.3.0': {} @@ -8500,7 +8506,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + '@vitejs/plugin-react@4.7.0(vite@7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -8508,7 +8514,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + vite: 7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -8529,23 +8535,23 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(msw@2.12.13(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + '@vitest/mocker@3.2.4(msw@2.12.13(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.6(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.13(@types/node@25.5.0)(typescript@5.9.3) - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + vite: 7.3.6(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) - '@vitest/mocker@4.1.0(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + '@vitest/mocker@4.1.0(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.13(@types/node@20.19.37)(typescript@5.9.3) - vite: 7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + vite: 7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -8624,7 +8630,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.3 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -8750,20 +8756,20 @@ snapshots: before-after-hook@3.0.2: {} - better-auth@1.6.11(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@20.19.37)(jsdom@29.0.0(@noble/hashes@2.2.0))(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))): - dependencies: - '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)) - '@better-auth/kysely-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) - '@better-auth/memory-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/mongo-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/prisma-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/telemetry': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) - '@better-auth/utils': 0.4.0 - '@better-fetch/fetch': 1.1.21 + better-auth@1.6.23(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(next@16.2.10(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@20.19.37)(jsdom@29.0.0(@noble/hashes@2.2.0))(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))): + dependencies: + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)) + '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.2.0 '@noble/hashes': 2.2.0 - better-call: 1.3.5(zod@4.3.6) + better-call: 1.3.7(zod@4.3.6) defu: 6.1.7 jose: 6.2.2 kysely: 0.28.17 @@ -8773,18 +8779,18 @@ snapshots: better-sqlite3: 12.10.0 drizzle-kit: 0.31.10 drizzle-orm: 0.45.2(@electric-sql/pglite@0.2.17)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9) - next: 16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.10(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 4.1.0(@types/node@20.19.37)(jsdom@29.0.0(@noble/hashes@2.2.0))(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + vitest: 4.1.0(@types/node@20.19.37)(jsdom@29.0.0(@noble/hashes@2.2.0))(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.3.5(zod@4.3.6): + better-call@1.3.7(zod@4.3.6): dependencies: - '@better-auth/utils': 0.4.0 - '@better-fetch/fetch': 1.1.21 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 rou3: 0.7.12 set-cookie-parser: 3.1.0 optionalDependencies: @@ -9426,13 +9432,13 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.2.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.2.10(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.2.0 + '@next/eslint-plugin-next': 16.2.10 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -9465,7 +9471,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -9480,7 +9486,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9745,15 +9751,15 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} + fast-uri@3.1.3: {} fastq@1.20.1: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.5 fetch-blob@3.2.0: dependencies: @@ -9840,9 +9846,9 @@ snapshots: fuzzysort@3.1.0: {} - geist@1.7.0(next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + geist@1.7.0(next@16.2.10(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): dependencies: - next: 16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.10(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) gel@2.2.0: dependencies: @@ -9965,7 +9971,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.8: {} + hono@4.12.28: {} html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): dependencies: @@ -10236,7 +10242,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.24.4 + undici: 7.28.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -10405,7 +10411,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.54.0: {} @@ -10448,7 +10454,7 @@ snapshots: headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 - path-to-regexp: 6.3.0 + path-to-regexp: 8.4.2 picocolors: 1.1.1 rettime: 0.10.1 statuses: 2.0.2 @@ -10473,7 +10479,7 @@ snapshots: headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 - path-to-regexp: 6.3.0 + path-to-regexp: 8.4.2 picocolors: 1.1.1 rettime: 0.10.1 statuses: 2.0.2 @@ -10503,9 +10509,9 @@ snapshots: negotiator@1.0.0: {} - next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.10(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.2.0 + '@next/env': 16.2.10 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.9 caniuse-lite: 1.0.30001780 @@ -10514,14 +10520,14 @@ snapshots: react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.0 - '@next/swc-darwin-x64': 16.2.0 - '@next/swc-linux-arm64-gnu': 16.2.0 - '@next/swc-linux-arm64-musl': 16.2.0 - '@next/swc-linux-x64-gnu': 16.2.0 - '@next/swc-linux-x64-musl': 16.2.0 - '@next/swc-win32-arm64-msvc': 16.2.0 - '@next/swc-win32-x64-msvc': 16.2.0 + '@next/swc-darwin-arm64': 16.2.10 + '@next/swc-darwin-x64': 16.2.10 + '@next/swc-linux-arm64-gnu': 16.2.10 + '@next/swc-linux-arm64-musl': 16.2.10 + '@next/swc-linux-x64-gnu': 16.2.10 + '@next/swc-linux-x64-musl': 16.2.10 + '@next/swc-win32-arm64-msvc': 16.2.10 + '@next/swc-win32-x64-msvc': 16.2.10 '@playwright/test': 1.58.2 sharp: 0.34.5 transitivePeerDependencies: @@ -10696,9 +10702,7 @@ snapshots: path-parse@1.0.7: {} - path-to-regexp@6.3.0: {} - - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} pathe@2.0.3: {} @@ -10706,9 +10710,9 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.5: {} pkce-challenge@5.0.1: {} @@ -11075,7 +11079,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color @@ -11486,8 +11490,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@1.1.1: {} @@ -11630,7 +11634,7 @@ snapshots: undici-types@7.18.2: {} - undici@7.24.4: {} + undici@7.28.0: {} unicorn-magic@0.3.0: {} @@ -11724,7 +11728,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + vite: 7.3.6(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - jiti @@ -11739,11 +11743,11 @@ snapshots: - tsx - yaml - vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0): + vite@7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0): dependencies: esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 postcss: 8.5.8 rollup: 4.59.0 tinyglobby: 0.2.15 @@ -11754,11 +11758,11 @@ snapshots: lightningcss: 1.32.0 tsx: 4.21.0 - vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0): + vite@7.3.6(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0): dependencies: esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 postcss: 8.5.8 rollup: 4.59.0 tinyglobby: 0.2.15 @@ -11773,7 +11777,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.13(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(msw@2.12.13(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.6(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -11784,14 +11788,14 @@ snapshots: expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.5 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + vite: 7.3.6(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: @@ -11811,10 +11815,10 @@ snapshots: - tsx - yaml - vitest@4.1.0(@types/node@20.19.37)(jsdom@29.0.0(@noble/hashes@2.2.0))(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)): + vitest@4.1.0(@types/node@20.19.37)(jsdom@29.0.0(@noble/hashes@2.2.0))(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + '@vitest/mocker': 4.1.0(msw@2.12.13(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -11825,13 +11829,13 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.5 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + vite: 7.3.6(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.37 diff --git a/scripts/com.runforge.daemon.plist b/scripts/com.runforge.daemon.plist index 530d7d2c..1e3b5442 100644 --- a/scripts/com.runforge.daemon.plist +++ b/scripts/com.runforge.daemon.plist @@ -35,6 +35,8 @@ __DAEMON_DATA_BACKEND__ ENCRYPTION_KEY __ENCRYPTION_KEY__ + RUNFORGE_CONTROL_TOKEN + __RUNFORGE_CONTROL_TOKEN__ diff --git a/scripts/install-daemon.sh b/scripts/install-daemon.sh index 05629505..a1e7c162 100755 --- a/scripts/install-daemon.sh +++ b/scripts/install-daemon.sh @@ -5,13 +5,34 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" PLIST_SRC="$REPO_ROOT/scripts/com.runforge.daemon.plist" PLIST_DST="$HOME/Library/LaunchAgents/com.runforge.daemon.plist" -ENV_FILE="$REPO_ROOT/.env.mac" +ENV_FILE="${RUNFORGE_ENV_MAC_PATH:-$REPO_ROOT/.env.mac}" LOG_DIR="$HOME/logs" NPX_PATH="$(which npx)" CURRENT_PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin" log() { echo "[$(date '+%H:%M:%S')] $*"; } +# 0. Ensure RUNFORGE_CONTROL_TOKEN is provisioned in the env file (idempotent) +provision_control_token() { + if [ ! -f "$ENV_FILE" ]; then + return + fi + + # Keep the env file private even if it already exists. + chmod 600 "$ENV_FILE" 2>/dev/null || true + + # Check the file itself, not inherited shell env, so a token exported in the + # operator's shell does not mask a missing .env.mac entry. + if grep -q '^RUNFORGE_CONTROL_TOKEN=' "$ENV_FILE"; then + return + fi + + local token + token="$(openssl rand -hex 32)" + printf '\nRUNFORGE_CONTROL_TOKEN=%s\n' "$token" >> "$ENV_FILE" + log "Generated RUNFORGE_CONTROL_TOKEN in $ENV_FILE" +} + require_env() { local name="$1" local value="${!name:-}" @@ -41,6 +62,8 @@ if [ ! -f "$ENV_FILE" ]; then exit 1 fi +provision_control_token + log "Reading environment from $ENV_FILE..." # Source the env file to get variable values set -a @@ -51,6 +74,7 @@ set +a DAEMON_DATA_BACKEND_VALUE="${DAEMON_DATA_BACKEND:-postgres}" RUNFORGE_DATABASE_URL_VALUE="${RUNFORGE_DATABASE_URL:-}" ENCRYPTION_KEY_VALUE="${ENCRYPTION_KEY:-}" +RUNFORGE_CONTROL_TOKEN_VALUE="${RUNFORGE_CONTROL_TOKEN:-}" case "$DAEMON_DATA_BACKEND_VALUE" in postgres) ;; @@ -63,6 +87,7 @@ esac require_env GITHUB_TOKEN require_env RUNFORGE_DATABASE_URL require_env ENCRYPTION_KEY +require_env RUNFORGE_CONTROL_TOKEN log "Installing daemon plist..." log " npx: $NPX_PATH" @@ -78,6 +103,7 @@ sed \ -e "s|__RUNFORGE_DATABASE_URL__|${RUNFORGE_DATABASE_URL_VALUE}|g" \ -e "s|__DAEMON_DATA_BACKEND__|${DAEMON_DATA_BACKEND_VALUE}|g" \ -e "s|__ENCRYPTION_KEY__|${ENCRYPTION_KEY_VALUE}|g" \ + -e "s|__RUNFORGE_CONTROL_TOKEN__|${RUNFORGE_CONTROL_TOKEN_VALUE}|g" \ "$PLIST_SRC" > "$PLIST_TMP" mv "$PLIST_TMP" "$PLIST_DST" diff --git a/scripts/sync-claude-creds.sh b/scripts/sync-claude-creds.sh index cfdc09b6..3134e21d 100755 --- a/scripts/sync-claude-creds.sh +++ b/scripts/sync-claude-creds.sh @@ -47,7 +47,13 @@ if ! creds_json="$(security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "$ exit 1 fi +# Ensure the mounted creds dir exists before writing the validation log. +mkdir -p "${CREDS_DIR}" + # 2. Validate shape (access token present) WITHOUT printing the secret. +# Write validation log to a 0600 file inside the creds dir, never world-readable /tmp. +VALIDATE_LOG="$(mktemp "${CREDS_DIR}/.sync-claude-creds.validate.XXXXXX")" +chmod 600 "${VALIDATE_LOG}" if ! printf '%s' "${creds_json}" | python3 -c ' import json,sys try: @@ -58,13 +64,13 @@ try: print(f"OK accessToken len={len(tok)} expiresAt={exp}", file=sys.stderr) except Exception as e: print(f"INVALID creds json: {e}", file=sys.stderr); sys.exit(1) -' 2>>/tmp/sync-claude-creds.validate; then - log "ERROR keychain credential did not validate (see /tmp/sync-claude-creds.validate)" +' 2>>"${VALIDATE_LOG}"; then + log "ERROR keychain credential did not validate (see ${VALIDATE_LOG})" exit 1 fi # 3. Atomic, 0600 write into the mounted creds dir. -mkdir -p "${CREDS_DIR}" +# (CREDS_DIR was created in step 2 before the validation log.) target="${CREDS_DIR}/.credentials.json" tmp="$(mktemp "${CREDS_DIR}/.credentials.json.XXXXXX")" trap 'rm -f "${tmp}"' EXIT