From 66c93ce11936458ddcead97f1b5b7072fcd7beb5 Mon Sep 17 00:00:00 2001 From: Benjamin Coriou Date: Sat, 22 Aug 2026 08:45:22 +0200 Subject: [PATCH 1/6] deps: bump hono 4.13.3 (clears 4 advisories) and biome 2.5.10 hono <4.12.34 has 3 moderate + 1 low advisories against our surface (SSR memo cross-request disclosure, CORS ReDoS, proxy header leak, language-middleware DoS). 4.13.3 is a caret-range bump; full suite re-greened. bun audit now clean. --- bun.lock | 24 ++++++++++++------------ package.json | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bun.lock b/bun.lock index 80a2569..3496b62 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,11 @@ "": { "name": "up-vector", "dependencies": { - "hono": "^4.12.30", + "hono": "^4.13.3", "zod": "^4.4.3", }, "devDependencies": { - "@biomejs/biome": "^2.5.4", + "@biomejs/biome": "^2.5.10", "@types/bun": "^1.3.14", "@upstash/vector": "^1.2.3", "typescript": "^6.0.3", @@ -17,23 +17,23 @@ }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.5.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.4", "@biomejs/cli-darwin-x64": "2.5.4", "@biomejs/cli-linux-arm64": "2.5.4", "@biomejs/cli-linux-arm64-musl": "2.5.4", "@biomejs/cli-linux-x64": "2.5.4", "@biomejs/cli-linux-x64-musl": "2.5.4", "@biomejs/cli-win32-arm64": "2.5.4", "@biomejs/cli-win32-x64": "2.5.4" }, "bin": { "biome": "bin/biome" } }, "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.10", "@biomejs/cli-darwin-x64": "2.5.10", "@biomejs/cli-linux-arm64": "2.5.10", "@biomejs/cli-linux-arm64-musl": "2.5.10", "@biomejs/cli-linux-x64": "2.5.10", "@biomejs/cli-linux-x64-musl": "2.5.10", "@biomejs/cli-win32-arm64": "2.5.10", "@biomejs/cli-win32-x64": "2.5.10" }, "bin": { "biome": "bin/biome" } }, "sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -43,7 +43,7 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "hono": ["hono@4.12.30", "", {}, "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog=="], + "hono": ["hono@4.13.3", "", {}, "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], diff --git a/package.json b/package.json index 39be8a6..ab2369f 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,11 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "hono": "^4.12.30", + "hono": "^4.13.3", "zod": "^4.4.3" }, "devDependencies": { - "@biomejs/biome": "^2.5.4", + "@biomejs/biome": "^2.5.10", "@types/bun": "^1.3.14", "@upstash/vector": "^1.2.3", "typescript": "^6.0.3" From cd9d3d17ad365d57a3134eb8eb8e63f68700443a Mon Sep 17 00:00:00 2001 From: Benjamin Coriou Date: Sat, 22 Aug 2026 08:45:24 +0200 Subject: [PATCH 2/6] ci: weekly maintenance workflow, dependabot everywhere, pinned images - maintenance.yml (Mon 09:30 UTC): scheduled bun audit -> security issue; canary suites against bun@latest and redis-stack@latest -> maintenance issue; docker build + upsert/query smoke - dependabot: bun deps (all), github-actions, docker, docker-compose ecosystems; @upstash/vector stays ungrouped for individual review; typescript majors ignored (7.x hold) - pin redis-stack-server 7.4.0-v8 in CI + compose (bit-identical to current latest; canary now watches latest explicitly) - Dockerfile oven/bun 1 -> 1.3-alpine (minor-pinned) actionlint + shellcheck clean. --- .github/dependabot.yml | 51 ++++- .github/workflows/compat.yml | 4 +- .github/workflows/maintenance.yml | 298 ++++++++++++++++++++++++++++++ .github/workflows/test.yml | 4 +- Dockerfile | 4 +- docker-compose.yml | 4 +- 6 files changed, 355 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/maintenance.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4f9225d..e3e1bb5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,14 +1,55 @@ version: 2 updates: - - package-ecosystem: "npm" + # bun.lock dependencies. @upstash/vector stays ungrouped on purpose: it is + # the compatibility target, its bumps must be reviewed against the SDK + # compat suite individually, never folded into a tooling batch. + - package-ecosystem: "bun" directory: "/" schedule: interval: "weekly" - allow: - - dependency-name: "@upstash/vector" + open-pull-requests-limit: 5 + ignore: + # TypeScript 7 is a deliberate hold: migrate only with a verified + # dedicated pass (docs/RUNBOOK.md). + - dependency-name: "typescript" + update-types: ["version-update:semver-major"] commit-message: prefix: "deps:" + prefix-development: "deps-dev:" labels: - "dependencies" - - "sdk-compat" - open-pull-requests-limit: 1 + + # CI action pins across .github/workflows/ + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci:" + labels: + - "dependencies" + + # oven/bun base image in Dockerfile + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "deps:" + labels: + - "dependencies" + + # redis/redis-stack-server pin in docker-compose.yml + - package-ecosystem: "docker-compose" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "deps:" + labels: + - "dependencies" + - "redis-stack" diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index f0da576..630b8bf 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -15,7 +15,9 @@ jobs: runs-on: ubuntu-latest services: redis: - image: redis/redis-stack-server:latest + # Pinned baseline; redis-stack@latest is exercised weekly by the + # canary-redis job in maintenance.yml. Bump after that goes green. + image: redis/redis-stack-server:7.4.0-v8 ports: - 6379:6379 options: >- diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml new file mode 100644 index 0000000..ea520b3 --- /dev/null +++ b/.github/workflows/maintenance.yml @@ -0,0 +1,298 @@ +name: Maintenance + +# Weekly drift canaries + security audit. Everything here watches things that +# float OUTSIDE our lockfile: Bun runtime, Redis Stack image, npm advisory DB, +# and the Docker artifact path users actually deploy. +# +# Failure model: +# - Vulnerabilities found -> "Security: ..." issue (security-audit job) +# - Canary / smoke job failed -> "Maintenance: scheduled run failures" issue (notify job) +# Triage guidance for each signal: docs/RUNBOOK.md + +on: + schedule: + - cron: "30 9 * * 1" # Monday 09:30 UTC, after the SDK compat run (09:00) + workflow_dispatch: + +permissions: + contents: read + issues: write + +env: + BUN_PIN: "1.3.6" # must match test.yml / compat.yml + REDIS_PIN: "7.4.0-v8" # must match test.yml / compat.yml / docker-compose.yml + +jobs: + security-audit: + name: Security audit (bun audit) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_PIN }} + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: bun-${{ runner.os }}- + + - run: bun install --frozen-lockfile + + - name: Audit lockfile against advisory DB + id: audit + run: | + set +e + bun audit --json > audit.json + STATUS=$? + set -e + + # Empty output + failure = registry/network error, not "no vulns". + # Fail the job so the notify issue fires instead of staying silent. + if [ ! -s audit.json ]; then + echo "::error::bun audit produced no output (exit $STATUS) — infra failure" + exit 1 + fi + + COUNT=$(jq '[.[] | length] | add // 0' audit.json) + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + echo "Advisories found: $COUNT" + + if [ "$COUNT" -gt 0 ]; then + { + echo "| Advisory | Severity | Vulnerable versions |" + echo "| --- | --- | --- |" + jq -r '.[] | .[] | "| [\(.title)](\(.url)) | \(.severity) | \(.["vulnerable_versions"]) |"' audit.json + } > audit-report.md + fi + + - name: Open or update security issue + if: steps.audit.outputs.count != '0' + env: + GH_TOKEN: ${{ github.token }} + COUNT: ${{ steps.audit.outputs.count }} + run: | + TITLE="Security: ${COUNT} dependency vulnerabilities (bun audit)" + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + gh label create security --color D93F0B --description "Dependency vulnerabilities" 2>/dev/null || true + gh label create automated --color EDEDED --description "Opened by automation" 2>/dev/null || true + + # shellcheck disable=SC2016 + BODY=$(printf 'Scheduled `bun audit` found **%s** advisories against the lockfile.\n\n%s\n\n**Workflow run:** %s\n' \ + "$COUNT" "$(cat audit-report.md)" "$RUN_URL") + + EXISTING=$(gh issue list --state open --label security --json number,title \ + --jq ".[] | select(.title == \"$TITLE\") | .number" | head -1) + if [ -n "$EXISTING" ]; then + echo "Issue #$EXISTING already open for this advisory set, commenting" + gh issue comment "$EXISTING" --body "$BODY" + else + gh issue create --title "$TITLE" --label "security,automated" --body "$BODY" + fi + + canary-bun: + name: "Canary: bun@latest" + runs-on: ubuntu-latest + services: + redis: + image: redis/redis-stack-server:7.4.0-v8 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + env: + UPVECTOR_TOKEN: test-token-123 + UPVECTOR_REDIS_URL: redis://localhost:6379 + UPVECTOR_EMBEDDING_PROVIDER: fake + UPVECTOR_EMBEDDING_MODEL: fake-embedding + UPVECTOR_EMBEDDING_DIMENSION: 8 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install + - name: Bun version under test + run: bun --version + + - name: Start up-vector + run: | + bun run src/index.ts > up-vector.log 2>&1 & + echo $! > up-vector.pid + + - name: Wait for server + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:8080/health >/dev/null; then + echo "up-vector healthy after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::up-vector did not become healthy within 30s" + cat up-vector.log || true + exit 1 + + - name: Unit + integration + compatibility + run: | + export UPVECTOR_TOKEN=test + bun test tests/unit + bun test tests/integration + bun test tests/compatibility + + - name: up-vector logs on failure + if: failure() + run: cat up-vector.log || true + + canary-redis: + name: "Canary: redis-stack@latest" + runs-on: ubuntu-latest + services: + redis: + image: redis/redis-stack-server:latest + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + env: + UPVECTOR_TOKEN: test-token-123 + UPVECTOR_REDIS_URL: redis://localhost:6379 + UPVECTOR_EMBEDDING_PROVIDER: fake + UPVECTOR_EMBEDDING_MODEL: fake-embedding + UPVECTOR_EMBEDDING_DIMENSION: 8 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_PIN }} + + - run: bun install + + - name: Start up-vector + run: | + bun run src/index.ts > up-vector.log 2>&1 & + echo $! > up-vector.pid + + - name: Wait for server + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:8080/health >/dev/null; then + echo "up-vector healthy after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::up-vector did not become healthy within 30s" + cat up-vector.log || true + exit 1 + + - name: Integration + compatibility against latest Redis Stack + run: | + bun test tests/integration + bun test tests/compatibility + + - name: up-vector logs on failure + if: failure() + run: cat up-vector.log || true + + docker-smoke: + name: Docker build + smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build image + run: docker build -t up-vector:smoke . + + - name: Boot stack and exercise the built artifact + run: | + docker network create smoke + docker run -d --name redis --network smoke \ + redis/redis-stack-server:${{ env.REDIS_PIN }} + docker run -d --name upvector --network smoke \ + -e UPVECTOR_TOKEN=smoke-token \ + -e UPVECTOR_REDIS_URL=redis://redis:6379 \ + up-vector:smoke + + curl_() { + docker run --rm --network smoke curlimages/curl:latest "$@" + } + + ok=0 + for i in $(seq 1 30); do + if curl_ -sf http://upvector:8080/health >/dev/null; then + echo "healthy after ${i}s" + ok=1 + break + fi + sleep 1 + done + if [ "$ok" != "1" ]; then + echo "::error::container never became healthy" + docker logs upvector || true + exit 1 + fi + + # Roundtrip through the deployed artifact: upsert then query. + curl_ -sf -X POST http://upvector:8080/upsert \ + -H "Authorization: Bearer smoke-token" \ + -H "Content-Type: application/json" \ + -d '{"id":"smoke-1","vector":[0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8],"metadata":{"source":"smoke"}}' + echo + + curl_ -sf -X POST http://upvector:8080/query \ + -H "Authorization: Bearer smoke-token" \ + -H "Content-Type: application/json" \ + -d '{"vector":[0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8],"topK":1,"includeVectors":false}' + echo + + - name: Cleanup + if: always() + run: docker rm -f redis upvector 2>/dev/null || true + + notify: + name: Open issue on failures + needs: [security-audit, canary-bun, canary-redis, docker-smoke] + if: ${{ failure() }} + runs-on: ubuntu-latest + steps: + - name: Create or comment on failure issue + env: + GH_TOKEN: ${{ github.token }} + run: | + # shellcheck disable=SC2016 + FAILED=$(jq -r '[to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key] | join(", ")' \ + <<< '${{ toJSON(needs) }}') + if [ -z "$FAILED" ]; then + echo "No failed jobs, nothing to report" + exit 0 + fi + + TITLE="Maintenance: scheduled run failures" + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + gh label create maintenance --color FBCA04 --description "Scheduled maintenance run failures" 2>/dev/null || true + gh label create automated --color EDEDED --description "Opened by automation" 2>/dev/null || true + + # shellcheck disable=SC2016 + BODY=$(printf 'Scheduled maintenance run failed.\n\n**Failed jobs:** %s\n\n**Workflow run:** %s\n\nTriage guidance: `docs/RUNBOOK.md`.\n' \ + "$FAILED" "$RUN_URL") + + EXISTING=$(gh issue list --state open --label maintenance --json number,title \ + --jq ".[] | select(.title == \"$TITLE\") | .number" | head -1) + if [ -n "$EXISTING" ]; then + echo "Issue #$EXISTING already open, commenting" + gh issue comment "$EXISTING" --body "$BODY" + else + gh issue create --title "$TITLE" --label "maintenance,automated" --body "$BODY" + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f54ca6..6fb658e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,7 +37,9 @@ jobs: needs: unit services: redis: - image: redis/redis-stack-server:latest + # Pinned baseline; redis-stack@latest is exercised weekly by the + # canary-redis job in maintenance.yml. Bump after that goes green. + image: redis/redis-stack-server:7.4.0-v8 ports: - 6379:6379 options: >- diff --git a/Dockerfile b/Dockerfile index 0b7bd15..3b2c144 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1-alpine AS builder +FROM oven/bun:1.3-alpine AS builder WORKDIR /app @@ -11,7 +11,7 @@ RUN bun build src/index.ts --target=bun --outdir=dist --minify # --- -FROM oven/bun:1-alpine +FROM oven/bun:1.3-alpine WORKDIR /app diff --git a/docker-compose.yml b/docker-compose.yml index 3e9e547..824a3e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,9 @@ services: restart: unless-stopped redis: - image: redis/redis-stack-server:latest + # Pinned for production stability. Dependabot (docker-compose ecosystem) + # proposes bumps; take them after the weekly canary-redis job is green. + image: redis/redis-stack-server:7.4.0-v8 volumes: - redis-data:/data healthcheck: From 42bd9342f958a645b4830646f30682c451a3e262 Mon Sep 17 00:00:00 2001 From: Benjamin Coriou Date: Sat, 22 Aug 2026 08:45:27 +0200 Subject: [PATCH 3/6] fix: observe timed-out handlers, detect index metric mismatch, timing-safe metrics token - timeout middleware: attach an observer to the losing next() chain so a late rejection logs instead of becoming an unhandledRejection - ensureIndex: parse DISTANCE_METRIC from FT.INFO (RESP3 + RESP2) and fail loudly when an existing index was built under a different UPVECTOR_METRIC; previously only dimension was validated, so a metric change silently mis-normalized scores - /metrics token: timingSafeEqual with length pre-check, matching the main auth middleware 381 tests (247 unit / 60 integration / 74 compat) green via scripts/test-all.sh against redis-stack 7.4.0-v8. --- src/middleware/timeout.ts | 34 ++++++++----- src/routes/metrics.ts | 13 ++++- src/translate/index.ts | 90 +++++++++++++++++++++++++++++++---- tests/unit/index-info.test.ts | 64 +++++++++++++++++++++++++ tests/unit/timeout.test.ts | 30 ++++++++++++ 5 files changed, 209 insertions(+), 22 deletions(-) create mode 100644 tests/unit/index-info.test.ts diff --git a/src/middleware/timeout.ts b/src/middleware/timeout.ts index 21547a9..93318a1 100644 --- a/src/middleware/timeout.ts +++ b/src/middleware/timeout.ts @@ -15,18 +15,28 @@ export const timeoutMiddleware: MiddlewareHandler = async (c, next) => { timeoutId = setTimeout(() => resolve(TIMEOUT_SENTINEL), config.requestTimeout) }) - const result = await Promise.race([ - next() - .then(() => { - clearTimeout(timeoutId) - return undefined - }) - .catch((err) => { - clearTimeout(timeoutId) - throw err - }), - timeoutPromise, - ]) + const work = next() + .then(() => { + clearTimeout(timeoutId) + return undefined + }) + .catch((err) => { + clearTimeout(timeoutId) + throw err + }) + + // If the timeout wins the race we respond 504 while `work` is still + // pending. Attach an observer so a late rejection is logged instead of + // surfacing as an unhandledRejection at process level. + work.catch((err) => { + log.warn("late failure after request timeout", { + method: c.req.method, + path: c.req.path, + error: err instanceof Error ? err.message : String(err), + }) + }) + + const result = await Promise.race([work, timeoutPromise]) if (result === TIMEOUT_SENTINEL) { log.warn("request timeout", { diff --git a/src/routes/metrics.ts b/src/routes/metrics.ts index 3eaf37d..cfab1f1 100644 --- a/src/routes/metrics.ts +++ b/src/routes/metrics.ts @@ -1,14 +1,25 @@ +import { timingSafeEqual } from "node:crypto" import { Hono } from "hono" import { HTTPException } from "hono/http-exception" import { config } from "../config" import { formatMetrics } from "../metrics" +// Length is compared in the clear — that is the standard accepted tradeoff +// (hono's own bearerAuth does the same); byte contents are compared in +// constant time. +function safeEqual(a: string, b: string): boolean { + const ab = new TextEncoder().encode(a) + const bb = new TextEncoder().encode(b) + if (ab.length !== bb.length) return false + return timingSafeEqual(ab, bb) +} + export function metricsAuthorizationOk( authorizationHeader: string | undefined, token: string | undefined, ): boolean { if (!token) return true - return authorizationHeader === `Bearer ${token}` + return authorizationHeader !== undefined && safeEqual(authorizationHeader, `Bearer ${token}`) } export function assertMetricsAuthorized( diff --git a/src/translate/index.ts b/src/translate/index.ts index 6e20224..227e361 100644 --- a/src/translate/index.ts +++ b/src/translate/index.ts @@ -101,24 +101,29 @@ async function createIndexInternal(ns: string, dimension: number, idx: string): ]) } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) + // Index exists from a previous run — query its actual dimension and + // distance metric and fail loudly if they don't match the configured + // values. Without the metric check, restarting with a different + // UPVECTOR_METRIC against existing data silently normalizes scores + // with the wrong formula. if (msg.includes("Index already exists")) { - // Index exists from a previous run — query its actual dimension and - // fail loudly if the caller's dimension doesn't match. try { const info = await redis.send("FT.INFO", [idx]) const actualDim = parseDimensionFromInfo(info) if (actualDim !== undefined) { dimensionMap.set(ns, actualDim) knownIndexes.add(idx) - if (actualDim !== dimension) { - throw new ValidationError( - `Dimension mismatch: namespace expects ${actualDim}, got ${dimension}`, - ) - } + validateIndexCompatibility( + ns, + actualDim, + parseMetricFromInfo(info), + dimension, + config.metric, + ) return } } catch (infoErr) { - // Bubble up dimension mismatches; tolerate transient FT.INFO failures + // Bubble up validation mismatches; tolerate transient FT.INFO failures if (infoErr instanceof ValidationError) { throw infoErr } @@ -127,7 +132,6 @@ async function createIndexInternal(ns: string, dimension: number, idx: string): throw err } } - knownIndexes.add(idx) dimensionMap.set(ns, dimension) } @@ -250,3 +254,71 @@ function parseDimensionFromInfo(info: any): number | undefined { } return undefined } + +// FT.INFO returns either a RESP3 object ({ attributes: [{...}] }) or a RESP2 +// flat array ([..., "attributes", [[...], ...]]). The shape varies by Redis +// version, so narrow defensively instead of trusting one shape. +function infoAttributes(info: unknown): unknown[] { + // RESP3 object form: { attributes: [{...}, ...] } + if (typeof info === "object" && info !== null && !Array.isArray(info)) { + const attrs = (info as Record).attributes + if (Array.isArray(attrs)) { + return attrs.filter((a: unknown) => Array.isArray(a) || (typeof a === "object" && a !== null)) + } + } + // RESP2 flat form: [..., "attributes", [["TYPE","FLOAT32",...], ...]] + if (Array.isArray(info)) { + for (let i = 0; i < info.length - 1; i++) { + if (info[i] === "attributes" && Array.isArray(info[i + 1])) { + return info[i + 1].filter( + (a: unknown) => Array.isArray(a) || (typeof a === "object" && a !== null), + ) + } + } + } + return [] +} + +export function parseMetricFromInfo(info: unknown): string | undefined { + for (const field of infoAttributes(info)) { + if (Array.isArray(field)) { + // Attribute rendered as alternating key/value pairs. + for (let j = 0; j < field.length - 1; j++) { + if ( + String(field[j]).toUpperCase() === "DISTANCE_METRIC" && + typeof field[j + 1] !== "undefined" + ) { + return String(field[j + 1]) + } + } + } else if (typeof field === "object" && field !== null) { + const record = field as Record // narrowed by the guard above + const metric = record.distance_metric ?? record.DISTANCE_METRIC + if (typeof metric === "string") return metric + } + } + return undefined +} + +/** + * Compare an existing index's FT.INFO attributes against what we would have + * created. Mismatch means the operator changed UPVECTOR_DIMENSION or + * UPVECTOR_METRIC against data written under different settings — queries and + * upserts must fail loudly instead of silently mis-scoring. + */ +export function validateIndexCompatibility( + ns: string, + actualDim: number | undefined, + actualMetric: string | undefined, + wantDim: number, + wantMetric: string, +): void { + if (actualMetric !== undefined && actualMetric.toUpperCase() !== wantMetric.toUpperCase()) { + throw new ValidationError( + `Distance metric mismatch: namespace "${ns}" index expects ${actualMetric}, configured ${wantMetric}`, + ) + } + if (actualDim !== undefined && actualDim !== wantDim) { + throw new ValidationError(`Dimension mismatch: namespace expects ${actualDim}, got ${wantDim}`) + } +} diff --git a/tests/unit/index-info.test.ts b/tests/unit/index-info.test.ts new file mode 100644 index 0000000..cfc9e78 --- /dev/null +++ b/tests/unit/index-info.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import { ValidationError } from "../../src/errors" +import { parseMetricFromInfo, validateIndexCompatibility } from "../../src/translate/index" + +describe("parseMetricFromInfo", () => { + test("reads distance_metric from RESP3 object shape", () => { + const info = { + num_docs: 3, + attributes: [{ identifier: "vec", type: "VECTOR", dim: "8", distance_metric: "COSINE" }], + } + expect(parseMetricFromInfo(info)).toBe("COSINE") + }) + + test("reads DISTANCE_METRIC from RESP2 flat array shape", () => { + const info = [ + "num_docs", + "3", + "attributes", + [["identifier", "vec", "TYPE", "FLOAT32", "DIM", "8", "DISTANCE_METRIC", "EUCLIDEAN"]], + ] + expect(parseMetricFromInfo(info)).toBe("EUCLIDEAN") + }) + + test("returns undefined when no attribute carries a metric", () => { + expect(parseMetricFromInfo({ attributes: [{ dim: "8" }] })).toBeUndefined() + expect(parseMetricFromInfo(undefined)).toBeUndefined() + expect(parseMetricFromInfo("garbage")).toBeUndefined() + }) +}) + +describe("validateIndexCompatibility", () => { + test("accepts a matching index", () => { + expect(() => validateIndexCompatibility("ns", 8, "COSINE", 8, "COSINE")).not.toThrow() + }) + + test("comparison is case-insensitive on metric", () => { + expect(() => validateIndexCompatibility("ns", 8, "cosine", 8, "COSINE")).not.toThrow() + }) + + test("rejects a metric mismatch loudly", () => { + expect(() => validateIndexCompatibility("movies", 8, "EUCLIDEAN", 8, "COSINE")).toThrow( + ValidationError, + ) + try { + validateIndexCompatibility("movies", 8, "EUCLIDEAN", 8, "COSINE") + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + expect(msg).toContain("Distance metric mismatch") + expect(msg).toContain("movies") + expect(msg).toContain("EUCLIDEAN") + expect(msg).toContain("COSINE") + } + }) + + test("keeps rejecting a dimension mismatch with the established message", () => { + expect(() => validateIndexCompatibility("ns", 16, "COSINE", 8, "COSINE")).toThrow( + /Dimension mismatch: namespace expects 16, got 8/, + ) + }) + + test("skips checks for attributes the index info did not expose", () => { + expect(() => validateIndexCompatibility("ns", undefined, undefined, 8, "COSINE")).not.toThrow() + }) +}) diff --git a/tests/unit/timeout.test.ts b/tests/unit/timeout.test.ts index 190603d..48409f3 100644 --- a/tests/unit/timeout.test.ts +++ b/tests/unit/timeout.test.ts @@ -42,4 +42,34 @@ describe("timeoutMiddleware", () => { const res = await app.request("/") expect(res.status).toBe(200) }) + + test("late rejection from a timed-out handler never becomes an unhandledRejection", async () => { + config.requestTimeout = 25 + const { promise: gate, resolve: releaseHandler } = Promise.withResolvers() + const unhandled: unknown[] = [] + const listener = (reason: unknown) => { + unhandled.push(reason) + } + process.on("unhandledRejection", listener) + try { + const app = appWith(async () => { + await gate + throw new Error("late boom") + }) + // The 504 resolves while the handler is parked on the gate, so the + // handler's rejection is guaranteed to happen after the race. + const res = await app.request("/") + expect(res.status).toBe(504) + + releaseHandler() + // Drain macrotask turns so a pending unhandledRejection would have + // been emitted by now; no wall-clock waiting involved. + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setImmediate(resolve)) + } + expect(unhandled).toEqual([]) + } finally { + process.off("unhandledRejection", listener) + } + }) }) From c16cb812c2b6fc977bbfec8f89d238eca52c3adf Mon Sep 17 00:00:00 2001 From: Benjamin Coriou Date: Sat, 22 Aug 2026 08:45:28 +0200 Subject: [PATCH 4/6] docs: maintenance runbook, deep audit record, refreshed counts RUNBOOK.md documents the recurring system: automation inventory, triage playbook per failure signal, release gate, and the deep-audit workflowz (parallel adversarial finders + refute-voting). First run recorded in docs/audits/2026-08-22-deep-audit.md: 15 raw findings, 6 confirmed, 3 fixed, 3 open for triage. --- CLAUDE.md | 3 +- docs/RUNBOOK.md | 117 +++++++++++++++++++++++++++ docs/audits/2026-08-22-deep-audit.md | 32 ++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 docs/RUNBOOK.md create mode 100644 docs/audits/2026-08-22-deep-audit.md diff --git a/CLAUDE.md b/CLAUDE.md index 757ac43..61aedb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ All prefixed `UPVECTOR_`: ## Implementation Status Phases 1-6 complete. Dense-vector CRUD + query + filtering + namespaces + production hardening + provider-backed `/upsert-data`/`/query-data`. -372 tests passing (238 unit, 60 integration, 74 SDK compatibility). +381 tests passing (247 unit, 60 integration, 74 SDK compatibility). Production hardening includes structured JSON logging, graceful shutdown, health probes, request timeouts, Prometheus metrics (optional scrape token), body limits, binary vector round-trip protection, Redis client self-heal, and process-level error handlers. See `PLAN.md` for the full architecture and phase breakdown. @@ -169,6 +169,7 @@ See `PLAN.md` for the full architecture and phase breakdown. - [Upstash Vector REST API](https://upstash.com/docs/vector/api/endpoints) — the API we're replicating - [@upstash/vector SDK](https://github.com/upstash/vector-js) — client SDK, also our compatibility test target +- [Maintenance Runbook](docs/RUNBOOK.md) — recurring automation (canaries, security audit, Dependabot), triage playbook, release gate, deep-audit procedure - [RediSearch vector search](https://redis.io/docs/latest/develop/interact/search-and-query/query/vector-search/) - [RediSearch FT.CREATE](https://redis.io/docs/latest/commands/ft.create/) / [FT.SEARCH](https://redis.io/docs/latest/commands/ft.search/) - [up-redis](https://github.com/Coriou/up-redis) — sibling project (same pattern, but for standard Redis commands) diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 0000000..318188c --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,117 @@ +# Maintenance Runbook + +How up-vector stays trustworthy without heroics. The system has two layers: + +1. **Always-on automation** (GitHub Actions + Dependabot) — watches everything that + floats outside our lockfile and turns every signal into a labeled GitHub issue. +2. **Scheduled deep audits** (agent fan-out) — adversarial review passes that hunt + for what automation cannot see: behavioral bugs, parity gaps, unsafe edge cases. + +Principles: + +- Nothing floats silently: Bun, Redis Stack, the SDK, and the npm advisory DB are + all either pinned or canaried. A floating version is allowed only as an explicit + canary whose job is to fail loudly. +- Pins never move because a version exists. They move when the corresponding + canary proves the new version green. +- Every automated signal lands as an issue with a stable label. No signal lives + only in a log nobody reads. + +Verified baseline at runbook creation (2026-08-22): **381 tests** — 247 unit / +60 integration / 74 SDK compatibility — green on Bun 1.3.6-pinned CI, local Bun +1.3.14, Redis Stack `7.4.0-v8`, hono `4.13.3`. + +## Automation inventory + +| Mechanism | When | Watches | Failure signal | +|---|---|---|---| +| `test.yml` | push / PR touching code | our code vs pinned Bun 1.3.6 + Redis Stack `7.4.0-v8` | red CI on the PR | +| `compat.yml` | Mon 09:00 UTC | `@upstash/vector@latest` against us | issue labeled `sdk-compat` | +| `maintenance.yml` | Mon 09:30 UTC | npm advisory DB, `bun@latest`, `redis-stack-server:latest`, Docker artifact | issues labeled `security` / `maintenance` | +| `dependabot.yml` | weekly | bun deps, GitHub Actions pins, Dockerfile base image, compose Redis pin | PRs labeled `dependencies` | + +## Triage playbook + +### `sdk-compat` issue (Upstash shipped an SDK change) + +1. Read the workflow log; identify failing tests. +2. Diff the SDK changelog between our pin and the failing version. +3. Dense-surface behavior change → fix forward in `src/`, extend + `tests/compatibility/`, merge. Breaking change outside our dense scope → + pin the last-green SDK version via a Dependabot `ignore` rule and open a + tracking issue describing the gap. +4. Do not leave main red past Monday — downstream users mirror this check. + +### `security` issue (`bun audit` found advisories) + +1. For each advisory, check whether we actually exercise the vulnerable code + path (e.g. which Hono middlewares/helpers `src/server.ts` mounts). +2. Fix available within existing semver range → bump, run `./scripts/test-all.sh`, + merge same week. Moderate-or-worse with no fix → assess exploitability of our + specific surface, document the analysis in the issue, decide mitigation. +3. Reference point: hono advisories GHSA-8j4g-w8fx-2239, GHSA-f23p-vx2j-j53r, + GHSA-79qm-7rj5-m7r9, GHSA-54fx-42gc-7vw4 were all resolved by 4.12.34; + we ship ≥ 4.13.3. + +### `maintenance` issue (canary or smoke failed) + +- **canary-bun failed** → newer Bun broke something. Hold `BUN_PIN` + (`maintenance.yml`, `test.yml`, `compat.yml`) and the Dockerfile base tag; + reproduce locally with `bun@latest`; bump only after a green full gate. +- **canary-redis failed** → RediSearch drifted. Hold `REDIS_PIN` everywhere + including `docker-compose.yml` (users deploy that pin). Reproduce against + `redis-stack-server:latest` locally before changing any `FT.*` usage in + `src/translate/index.ts` or route queries. +- **docker-smoke failed** → the artifact path users actually deploy is broken. + This is a release blocker; fix before anything ships. + +### Dependabot PRs + +- Merge after CI green. `@upstash/vector` PRs always get individual review — + the SDK is our compatibility contract, never batch it with tooling bumps. +- `typescript` major updates are deliberately ignored in config (7.x hold) + until a dedicated migration pass. + +## Release gate + +Before tagging or publishing any release: + +- [ ] `./scripts/test-all.sh` fully green; record counts in the release notes +- [ ] `bun audit --json` clean +- [ ] Latest weekly `compat.yml` + `maintenance.yml` runs green (re-run via + `workflow_dispatch` if stale) +- [ ] Docker build + smoke path green +- [ ] `README.md` / `CLAUDE.md` env tables and test counts still truthful + +## Deep audit (the workflowz) + +Run quarterly, before notable releases, and after upstream majors (SDK, Bun, +Redis Stack). Executed with the agent harness as a parallel fan-out; the +procedure is deterministic and re-runnable. Past runs land in `docs/audits/`. + +1. **Finders (parallel, mutually blind)** — one agent per dimension: + - *protocol correctness*: Float32 little-endian round-trips, RESP3 shape + handling, score normalization per metric (COSINE / EUCLIDEAN / + DOT_PRODUCT), binary-safe Redis paths (`redis.send`) + - *security*: auth bypass vectors, injection through ids / namespaces / + filter strings, header handling, body-limit enforcement + - *resource safety*: topK / dimension bombs, timeout coverage, unbounded + over-fetch in filter evaluation, shutdown drain + - *parity*: `_ENDPOINTS` inventory of the installed `@upstash/vector` vs + `src/routes/*` vs the live Upstash REST docs — every endpoint must be + supported, explicitly rejected, or explicitly 501 + - *concurrency & self-heal*: Redis client re-init, lazy `FT.CREATE` races, + graceful shutdown during in-flight writes + Each finder returns structured findings `{file, line, title, evidence, + severity}` — no vibes, citations required. +2. **Refutation (parallel)** — one skeptic per finding, prompted to REFUTE, + defaulting to "refuted" when evidence is weak. A finding survives only with + concrete file/line evidence the skeptic cannot break. +3. **Synthesis** — survivors are deduplicated, severity-ranked, and turned into + issues or immediate fixes. Disputed findings are reported as disputed, never + silently dropped. + +Scope guardrails for finders (things that look like bugs but are decisions): +sparse/hybrid vectors are intentionally rejected; resumable query endpoints are +intentionally 501; metadata filtering is intentionally app-level over-fetch in +v1; score values approximate Upstash cloud but are not bit-identical. diff --git a/docs/audits/2026-08-22-deep-audit.md b/docs/audits/2026-08-22-deep-audit.md new file mode 100644 index 0000000..65cc05d --- /dev/null +++ b/docs/audits/2026-08-22-deep-audit.md @@ -0,0 +1,32 @@ +# Deep Audit — 2026-08-22 + +First run of the deep-audit workflowz described in `docs/RUNBOOK.md`. Method: +5 mutually-blind finder agents (SDK parity, Upstash REST docs parity, protocol +correctness, security, resource/concurrency) produced **15 raw findings**; each +was attacked by a skeptic verifier prompted to refute (default-refute). **6 +survived** with concrete evidence; 9 were refuted. + +## Confirmed findings and dispositions + +| # | Severity | Dimension | Finding | Disposition | +|---|---|---|---|---| +| 1 | moderate | resource | `src/middleware/timeout.ts` — a request that times out leaves its handler chain unobserved; a late rejection became an `unhandledRejection` at process level | **Fixed** in the same pass: loser chain now observed + logged (`late failure after request timeout`). Regression test in `tests/unit/timeout.test.ts`. | +| 2 | moderate | protocol | `src/translate/index.ts` — existing-index validation compared only dimension; restarting with a different `UPVECTOR_METRIC` against existing data silently mis-normalized scores | **Fixed**: FT.INFO metric parsed (`parseMetricFromInfo`, RESP3 + RESP2 shapes) and `validateIndexCompatibility` fails loudly on metric or dimension mismatch. Tests in `tests/unit/index-info.test.ts`. | +| 3 | low | security | `src/routes/metrics.ts` — optional metrics token compared with plain string equality while main auth is timing-safe | **Fixed** for consistency: `timingSafeEqual` with length pre-check (same tradeoff as hono's bearerAuth). Existing `tests/unit/metrics-auth.test.ts` stays green. | +| 4 | moderate | protocol | Float32 overflow silent: f64 inputs like `1e300` pass Zod finiteness but become `±Infinity` in the stored/indexed vector (`src/translate/vectors.ts`) | **Open triage** — decide: reject non-float32-representable values at validation, or clamp+warn. Behavior change → needs compat-suite review before landing. | +| 5 | low | parity-docs | `/fetch` caps `ids` at 1000 (`src/routes/fetch.ts`); Upstash documents the cap only for prefix-mode fetch | **Open triage** — either lift the cap for explicit-id fetches or document the deviation in README parity table. | +| 6 | low | resource | Single-namespace reset (`src/routes/reset.ts`) runs dropIndex then key deletion non-atomically; an upsert racing between the steps can be wiped by the subsequent delete | **Open triage** — known distributed-race class; candidate fix: tombstone/version check or per-namespace reset mutex. Assess real-world likelihood before adding complexity. | + +## Refuted (9) — kept for the record + +Refutations fell into three buckets: claims contradicted by the actual code +(most common), behavior pinned deliberately by tests or scope decisions +(sparse-reject, resumable-501, app-level filtering), and exploit paths the +verifier could not construct against the real request flow. None are tracked +further; finders were re-prompted blind, so repeat runs re-test them. + +## Verification state at close of pass + +Full gate green after fixes: **381 tests** — 247 unit / 60 integration / 74 SDK +compatibility — via `./scripts/test-all.sh` on Bun 1.3.14 local / Redis Stack +`7.4.0-v8`; typecheck + Biome clean; `bun audit` clean on hono 4.13.3. From 8315b2a4760336bde80db5c4cff5b9fccad0d225 Mon Sep 17 00:00:00 2001 From: Benjamin Coriou Date: Sat, 22 Aug 2026 09:55:51 +0200 Subject: [PATCH 5/6] fix: review remediations from adversarial PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ensureIndex: compare FT.INFO metric against the Redis-native name (toRedisDistanceMetric) — raw config values (EUCLIDEAN/DOT_PRODUCT) would have spuriously failed every already-exists check for 2 of 3 metrics - syncIndexes/loadDimension: validate metric compatibility at boot and cold-cache paths too; previously only the already-exists branch checked, so a restart with a changed UPVECTOR_METRIC stayed silent; ValidationError rethrown instead of swallowed by generic catches - timeout middleware: attach the late-rejection observer inside the timeout branch only — unconditional attachment mislabeled every fast handler error as "late failure after request timeout" - maintenance.yml canary-bun: drop UPVECTOR_TOKEN=test step export that conflicted with the test-token-123 server env (guaranteed 401s) - docs: README counts 372/238 -> 381/249-unit table rows, pin redis-stack-server in README side-by-side example, CLAUDE.md container line matches Dockerfile pin Full gate green: 383 tests (249 unit / 60 integration / 74 compat). --- .github/workflows/maintenance.yml | 3 ++- CLAUDE.md | 2 +- README.md | 8 ++++---- src/middleware/timeout.ts | 22 +++++++++++----------- src/translate/index.ts | 27 +++++++++++++++++++-------- tests/unit/index-info.test.ts | 19 +++++++++++++++++++ 6 files changed, 56 insertions(+), 25 deletions(-) diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index ea520b3..e69decd 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -141,8 +141,9 @@ jobs: exit 1 - name: Unit + integration + compatibility + # Same token for server boot and suites — job env provides it; a + # step-level override would 401 every authenticated request. run: | - export UPVECTOR_TOKEN=test bun test tests/unit bun test tests/integration bun test tests/compatibility diff --git a/CLAUDE.md b/CLAUDE.md index 61aedb7..87526f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ Sibling project to [up-redis](https://github.com/Coriou/up-redis) (same idea, bu - **Validation:** Zod v4 - **Linting/Format:** Biome v2 - **Testing:** `bun test` (built-in, Jest-compatible) -- **Container:** Docker (oven/bun:alpine) + Redis Stack +- **Container:** Docker (oven/bun:1.3-alpine) + Redis Stack (pinned `7.4.0-v8`) **Not a Next.js/Vercel project.** Pure backend service. diff --git a/README.md b/README.md index c5b1e4e..f7c1dd0 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ curl -X POST http://localhost:8080/query-data \ ## API Compatibility -Implements the dense-vector subset of the [Upstash Vector REST API](https://upstash.com/docs/vector/api/endpoints), plus dense `/upsert-data` and `/query-data` through a configurable embedding provider. Validated by 372 tests including 74 using the real `@upstash/vector` SDK. +Implements the dense-vector subset of the [Upstash Vector REST API](https://upstash.com/docs/vector/api/endpoints), plus dense `/upsert-data` and `/query-data` through a configurable embedding provider. Validated by 381 tests including 74 using the real `@upstash/vector` SDK. | Surface | Status | Notes | |----------|--------|-------| @@ -290,11 +290,11 @@ bun run typecheck # TypeScript check ### Testing -372 tests across three tiers: +381 tests across three tiers: | Tier | Tests | Purpose | |------|-------|---------| -| **Unit** | 238 | Filter parser, embedding providers, vector encode/decode, score normalization, key naming, middleware/config hardening | +| **Unit** | 247 | Filter parser, embedding providers, vector encode/decode, score normalization, key naming, middleware/config hardening | | **Integration** | 60 | End-to-end REST behavior against Redis Stack, including raw-text data endpoints | | **SDK Compatibility** | 74 | Real `@upstash/vector` SDK against up-vector | @@ -324,7 +324,7 @@ Both services can share the same Redis Stack instance — up-redis handles stand ```yaml services: redis-stack: - image: redis/redis-stack-server:latest + image: redis/redis-stack-server:7.4.0-v8 up-redis: image: ghcr.io/coriou/up-redis:latest diff --git a/src/middleware/timeout.ts b/src/middleware/timeout.ts index 93318a1..378a668 100644 --- a/src/middleware/timeout.ts +++ b/src/middleware/timeout.ts @@ -25,20 +25,20 @@ export const timeoutMiddleware: MiddlewareHandler = async (c, next) => { throw err }) - // If the timeout wins the race we respond 504 while `work` is still - // pending. Attach an observer so a late rejection is logged instead of - // surfacing as an unhandledRejection at process level. - work.catch((err) => { - log.warn("late failure after request timeout", { - method: c.req.method, - path: c.req.path, - error: err instanceof Error ? err.message : String(err), - }) - }) - const result = await Promise.race([work, timeoutPromise]) if (result === TIMEOUT_SENTINEL) { + // The race settled on the timeout while `work` was still pending. + // Observe the loser now — attaching earlier would also catch errors + // that propagate normally through the race and mislabel every fast + // handler failure as a "late failure". + work.catch((err) => { + log.warn("late failure after request timeout", { + method: c.req.method, + path: c.req.path, + error: err instanceof Error ? err.message : String(err), + }) + }) log.warn("request timeout", { method: c.req.method, path: c.req.path, diff --git a/src/translate/index.ts b/src/translate/index.ts index 227e361..316c592 100644 --- a/src/translate/index.ts +++ b/src/translate/index.ts @@ -37,10 +37,15 @@ export async function loadDimension(ns: string): Promise { if (dim !== undefined) { dimensionMap.set(ns, dim) knownIndexes.add(idx) + // Metric is process-wide config; dimension is being discovered + // here, so only metric compatibility is checkable at this boundary. + validateIndexCompatibility(ns, dim, parseMetricFromInfo(info), undefined, config.metric) return dim } - } catch { - // Index does not exist yet — that's fine, the caller will create it + } catch (err) { + // Index does not exist yet — that's fine, the caller will create it. + // Compatibility mismatches must surface instead of being swallowed. + if (err instanceof ValidationError) throw err } return undefined } @@ -118,7 +123,9 @@ async function createIndexInternal(ns: string, dimension: number, idx: string): actualDim, parseMetricFromInfo(info), dimension, - config.metric, + // FT.INFO reports the Redis-native name (L2/IP/COSINE); + // compare against what we would have created. + toRedisDistanceMetric(config.metric), ) return } @@ -186,15 +193,19 @@ export async function syncIndexes(): Promise { for (const idx of indexes) { knownIndexes.add(idx) + const ns = idx.startsWith("idx:") ? idx.slice(4) : idx try { const info = await redis.send("FT.INFO", [idx]) const dim = parseDimensionFromInfo(info) if (dim !== undefined) { - const ns = idx.startsWith("idx:") ? idx.slice(4) : idx dimensionMap.set(ns, dim) } - } catch { - // Index may have been dropped between _LIST and INFO + validateIndexCompatibility(ns, dim, parseMetricFromInfo(info), undefined, config.metric) + } catch (err) { + // Index may have been dropped between _LIST and INFO; a + // compatibility mismatch aborts startup instead of mis-scoring + // every query for the process lifetime. + if (err instanceof ValidationError) throw err } } @@ -310,7 +321,7 @@ export function validateIndexCompatibility( ns: string, actualDim: number | undefined, actualMetric: string | undefined, - wantDim: number, + wantDim: number | undefined, wantMetric: string, ): void { if (actualMetric !== undefined && actualMetric.toUpperCase() !== wantMetric.toUpperCase()) { @@ -318,7 +329,7 @@ export function validateIndexCompatibility( `Distance metric mismatch: namespace "${ns}" index expects ${actualMetric}, configured ${wantMetric}`, ) } - if (actualDim !== undefined && actualDim !== wantDim) { + if (actualDim !== undefined && wantDim !== undefined && actualDim !== wantDim) { throw new ValidationError(`Dimension mismatch: namespace expects ${actualDim}, got ${wantDim}`) } } diff --git a/tests/unit/index-info.test.ts b/tests/unit/index-info.test.ts index cfc9e78..839f8c0 100644 --- a/tests/unit/index-info.test.ts +++ b/tests/unit/index-info.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { ValidationError } from "../../src/errors" import { parseMetricFromInfo, validateIndexCompatibility } from "../../src/translate/index" +import { toRedisDistanceMetric } from "../../src/translate/scores" describe("parseMetricFromInfo", () => { test("reads distance_metric from RESP3 object shape", () => { @@ -61,4 +62,22 @@ describe("validateIndexCompatibility", () => { test("skips checks for attributes the index info did not expose", () => { expect(() => validateIndexCompatibility("ns", undefined, undefined, 8, "COSINE")).not.toThrow() }) + + test("accepts the Redis-native names the create path actually stores", () => { + expect(() => + validateIndexCompatibility("ns", 8, "COSINE", 8, toRedisDistanceMetric("COSINE")), + ).not.toThrow() + expect(() => + validateIndexCompatibility("ns", 8, "L2", 8, toRedisDistanceMetric("EUCLIDEAN")), + ).not.toThrow() + expect(() => + validateIndexCompatibility("ns", 8, "IP", 8, toRedisDistanceMetric("DOT_PRODUCT")), + ).not.toThrow() + }) + + test("rejects when the stored Redis metric maps from a different config value", () => { + expect(() => + validateIndexCompatibility("ns", 8, "IP", 8, toRedisDistanceMetric("EUCLIDEAN")), + ).toThrow(/Distance metric mismatch/) + }) }) From 1abee0e7d5cb88cc4a757341cb1f707056b7ad5b Mon Sep 17 00:00:00 2001 From: Benjamin Coriou Date: Sat, 22 Aug 2026 09:57:20 +0200 Subject: [PATCH 6/6] docs: correct test counts to 383 (249 unit) after review-fix tests --- CLAUDE.md | 2 +- README.md | 6 +++--- docs/RUNBOOK.md | 2 +- docs/audits/2026-08-22-deep-audit.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 87526f8..57c6000 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ All prefixed `UPVECTOR_`: ## Implementation Status Phases 1-6 complete. Dense-vector CRUD + query + filtering + namespaces + production hardening + provider-backed `/upsert-data`/`/query-data`. -381 tests passing (247 unit, 60 integration, 74 SDK compatibility). +383 tests passing (249 unit, 60 integration, 74 SDK compatibility). Production hardening includes structured JSON logging, graceful shutdown, health probes, request timeouts, Prometheus metrics (optional scrape token), body limits, binary vector round-trip protection, Redis client self-heal, and process-level error handlers. See `PLAN.md` for the full architecture and phase breakdown. diff --git a/README.md b/README.md index f7c1dd0..cfae9d6 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ curl -X POST http://localhost:8080/query-data \ ## API Compatibility -Implements the dense-vector subset of the [Upstash Vector REST API](https://upstash.com/docs/vector/api/endpoints), plus dense `/upsert-data` and `/query-data` through a configurable embedding provider. Validated by 381 tests including 74 using the real `@upstash/vector` SDK. +Implements the dense-vector subset of the [Upstash Vector REST API](https://upstash.com/docs/vector/api/endpoints), plus dense `/upsert-data` and `/query-data` through a configurable embedding provider. Validated by 383 tests including 74 using the real `@upstash/vector` SDK. | Surface | Status | Notes | |----------|--------|-------| @@ -290,11 +290,11 @@ bun run typecheck # TypeScript check ### Testing -381 tests across three tiers: +383 tests across three tiers: | Tier | Tests | Purpose | |------|-------|---------| -| **Unit** | 247 | Filter parser, embedding providers, vector encode/decode, score normalization, key naming, middleware/config hardening | +| **Unit** | 249 | Filter parser, embedding providers, vector encode/decode, score normalization, key naming, middleware/config hardening | | **Integration** | 60 | End-to-end REST behavior against Redis Stack, including raw-text data endpoints | | **SDK Compatibility** | 74 | Real `@upstash/vector` SDK against up-vector | diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 318188c..893d186 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -17,7 +17,7 @@ Principles: - Every automated signal lands as an issue with a stable label. No signal lives only in a log nobody reads. -Verified baseline at runbook creation (2026-08-22): **381 tests** — 247 unit / +Verified baseline at runbook creation (2026-08-22): **383 tests** — 249 unit / 60 integration / 74 SDK compatibility — green on Bun 1.3.6-pinned CI, local Bun 1.3.14, Redis Stack `7.4.0-v8`, hono `4.13.3`. diff --git a/docs/audits/2026-08-22-deep-audit.md b/docs/audits/2026-08-22-deep-audit.md index 65cc05d..73ff8bc 100644 --- a/docs/audits/2026-08-22-deep-audit.md +++ b/docs/audits/2026-08-22-deep-audit.md @@ -27,6 +27,6 @@ further; finders were re-prompted blind, so repeat runs re-test them. ## Verification state at close of pass -Full gate green after fixes: **381 tests** — 247 unit / 60 integration / 74 SDK +Full gate green after fixes: **383 tests** — 249 unit / 60 integration / 74 SDK compatibility — via `./scripts/test-all.sh` on Bun 1.3.14 local / Redis Stack `7.4.0-v8`; typecheck + Biome clean; `bun audit` clean on hono 4.13.3.