diff --git a/.kiro/specs/phase-8-deployment/tasks.md b/.kiro/specs/phase-8-deployment/tasks.md index 2a4a872..57d003e 100644 --- a/.kiro/specs/phase-8-deployment/tasks.md +++ b/.kiro/specs/phase-8-deployment/tasks.md @@ -16,42 +16,42 @@ Design cross-references: [§ 2 Code layout](./design.md#code-layout), [§ 8 Test ## Batch A — Rate limiter module (memory backend default) -- [ ] **A1** Add bucket config and client-id helpers +- [x] **A1** Add bucket config and client-id helpers - Description: Create the pure helpers that underpin every backend — `BucketName` type, per-bucket limit + window constants, and the header-resolution function that returns the client id. - Files: `apps/api/src/rate-limit/buckets.ts` (new), `apps/api/src/rate-limit/client-id.ts` (new), `apps/api/src/rate-limit/buckets.test.ts` (new), `apps/api/src/rate-limit/client-id.test.ts` (new). - Acceptance criteria: R4.2, R4.3, R4.4. - Verification: unit tests assert `generation = 30/60s`, `read = 100/60s`, and header precedence `x-forwarded-for → cf-connecting-ip → "local"`; `pnpm --filter @stackfast/api test` passes. - Dependencies: none. -- [ ] **A2** Add memory backend +- [x] **A2** Add memory backend - Description: Port today's in-`Map` accounting behind the `RateLimitBackend` interface so the existing contract tests stay green and tests have a deterministic backend. - Files: `apps/api/src/rate-limit/memory.ts` (new), `apps/api/src/rate-limit/memory.test.ts` (new). - Acceptance criteria: preserves the observable behavior asserted by the existing `rate limits generation endpoints` case in `apps/api/src/app.test.ts`; regression net for R4.2, R4.3. - Verification: unit tests cover lazy rollover at `resetAt`, cross-bucket key isolation, and per-client isolation; `pnpm --filter @stackfast/api test` passes. - Dependencies: A1. -- [ ] **A3** Add fail-open wrapper +- [x] **A3** Add fail-open wrapper - Description: Wrap any backend so `check()` errors are swallowed, logged at most once per 60s, and the request is allowed through (R4.5). - Files: `apps/api/src/rate-limit/fail-open.ts` (new), `apps/api/src/rate-limit/fail-open.test.ts` (new). - Acceptance criteria: R4.5. - Verification: unit tests inject a backend whose `check()` rejects; assert the middleware calls `next()`, emits exactly one `[rate-limit] upstash unavailable` log inside a 60s window, and restores normal accounting on the next successful check. - Dependencies: A1, A2. -- [ ] **A4** Add Upstash backend (module only, no env wiring yet) +- [x] **A4** Add Upstash backend (module only, no env wiring yet) - Description: Implement `@upstash/ratelimit` + `@upstash/redis` sliding-window counter behind the `RateLimitBackend` interface. Do not switch `apps/api/src/app.ts` to it yet — the factory in A6 picks the backend from `RATE_LIMIT_BACKEND`. - Files: `apps/api/src/rate-limit/upstash.ts` (new), `apps/api/src/rate-limit/upstash.test.ts` (new), `apps/api/package.json` (edit: add `@upstash/ratelimit`, `@upstash/redis`). - Acceptance criteria: R4.1, R4.6. - Verification: unit tests mock `@upstash/redis`, assert that missing `UPSTASH_REDIS_REST_URL` / `_TOKEN` causes the factory to refuse construction (silently falling back to memory per design § 9), and that a successful response returns a `RateLimitDecision` whose `resetAtEpochMs` matches the window. `pnpm --filter @stackfast/api test` passes. - Dependencies: A1, A3. -- [ ] **A5** Add property-based test suite for the rate limiter `[pbt]` +- [x] **A5** Add property-based test suite for the rate limiter `[pbt]` - Description: Add the fast-check suite covering Property 1 from design § 8 — "Upstash failures never produce a 429 (fail-open)". This is the rate-limit PBT file; the Sentry PBT (Property 2) lands in B2 and the app-level PBTs (Properties 3–5) land in C2 and alongside them. - Files: `apps/api/src/rate-limit/rate-limit.pbt.test.ts` (new), root Vitest wiring if fast-check is not yet registered: `apps/api/package.json` (edit: add `fast-check` devDependency). - Acceptance criteria: R4.5 (as a property, not just the unit test from A3). - Verification: fast-check suite runs for the generator in design § 8 Property 1; every indexed request whose injected backend threw has final status in `{200, 401, 404}` and never `429`. Note that the test harness will flag the property-testing warning on this run. - Dependencies: A3, A4. -- [ ] **A6** Wire the new factory into the app and tighten contract tests +- [x] **A6** Wire the new factory into the app and tighten contract tests - Description: Replace the inline `rateLimit(bucket, limit)` factory body in `apps/api/src/app.ts` with a call to `createRateLimitMiddleware(bucket, limit)` (default backend = memory via `RATE_LIMIT_BACKEND`). Delete the dead `rateLimitBuckets` export and the `setInterval` cleanup in `apps/api/src/index.ts` — the memory backend rolls over lazily per request (design § 9 step 1). Add the four contract test cases named in design § 8. - Files: `apps/api/src/app.ts` (edit: swap factory body, drop `rateLimitBuckets` export), `apps/api/src/index.ts` (edit: remove `setInterval` and the stale-key cleanup TODO), `apps/api/src/app.test.ts` (edit: add cases `admin 401 before rate-limit counter increments`, `Retry-After only on 429`, `exempt routes never counted`, `bucket count survives backend swap`), `apps/api/src/rate-limit/index.ts` (new: public barrel exporting `createRateLimitMiddleware`, `rateLimitHealth`). - Acceptance criteria: R4.1, R4.7, R4.8, R4.9, R6.4, R8.1. @@ -62,35 +62,35 @@ Design cross-references: [§ 2 Code layout](./design.md#code-layout), [§ 8 Test ## Batch B — Sentry wiring behind `SENTRY_DSN` -- [ ] **B1** Add API Sentry module (init, scrubber, attach helper) +- [~] **B1** Add API Sentry module (init, scrubber, attach helper) - Description: Add `apps/api/src/observability/sentry.ts` exposing `initSentry()`, `attachSentryToHono(app)`, and `scrubEvent(event)`. Module is a no-op whenever `SENTRY_DSN` is falsy and idempotent across repeat calls. - Files: `apps/api/src/observability/sentry.ts` (new), `apps/api/src/observability/sentry.test.ts` (new), `apps/api/package.json` (edit: add `@sentry/node`). - Acceptance criteria: R7.1, R7.3, R7.4, R7.5, R7.6. - Verification: unit tests assert `Sentry.getCurrentHub().getClient()` is `undefined` when DSN is unset; exactly one client after any number of `initSentry()` calls with the same DSN; `release` equals `process.env.RAILWAY_GIT_COMMIT_SHA`; `scrubEvent` strips `idea` and `constraints` keys from `event.request.data` without mutating the input reference. - Dependencies: none (parallel with Batch A). -- [ ] **B2** Add property-based test for Sentry init idempotence `[pbt]` +- [~] **B2** Add property-based test for Sentry init idempotence `[pbt]` - Description: Add fast-check Property 2 from design § 8 — "Sentry init is idempotent and a no-op without DSN". - Files: `apps/api/src/observability/sentry.pbt.test.ts` (new). - Acceptance criteria: R7.3, R7.4. - Verification: fast-check replays any interleaving of `init` / `set-dsn` events and asserts the active-client invariant (0 clients when DSN always falsy, exactly 1 client once any non-empty DSN has been set). Note that the test harness will surface the property-testing warning on this run. - Dependencies: B1. -- [ ] **B3** Wire Sentry into the API process +- [~] **B3** Wire Sentry into the API process - Description: Call `initSentry()` in `apps/api/src/index.ts` before `serve()`, and call `attachSentryToHono(app)` in `apps/api/src/app.ts` so captured events include `requestId`. No-op stays silent when DSN is unset. - Files: `apps/api/src/index.ts` (edit), `apps/api/src/app.ts` (edit). - Acceptance criteria: R7.1, R7.3. - Verification: contract test asserts that with `SENTRY_DSN` unset, `Sentry.getCurrentHub().getClient()` is still `undefined` after `app.request("/health")`. With a stubbed DSN, one client is registered and a thrown error inside a route produces a captured event whose payload has `idea` / `constraints` removed. - Dependencies: B1, B2. -- [ ] **B4** Add web Sentry module +- [~] **B4** Add web Sentry module - Description: Add `apps/web/src/lib/sentry.ts` exposing a browser `initSentry()` that reads `import.meta.env.VITE_SENTRY_DSN` and `VITE_APP_RELEASE`. Idempotent; no-op when DSN is missing. - Files: `apps/web/src/lib/sentry.ts` (new), `apps/web/src/lib/sentry.test.ts` (new), `apps/web/package.json` (edit: add `@sentry/react`, `@sentry/vite-plugin`). - Acceptance criteria: R7.2, R7.3, R7.4. - Verification: unit tests cover the DSN-unset and double-init branches on the browser side the same way B1 does for the API. - Dependencies: none (can parallel B1). -- [ ] **B5** Wire Sentry into the web build and entrypoint +- [~] **B5** Wire Sentry into the web build and entrypoint - Description: Call `initSentry()` in `apps/web/src/main.tsx` before `ReactDOM.createRoot`. Register `sentryVitePlugin` conditionally in `apps/web/vite.config.ts` — enabled only when `SENTRY_DSN`, `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT_WEB` are all set at build time. - Files: `apps/web/src/main.tsx` (edit), `apps/web/vite.config.ts` (edit). - Acceptance criteria: R7.2, R7.3, R7.6. @@ -101,14 +101,14 @@ Design cross-references: [§ 2 Code layout](./design.md#code-layout), [§ 8 Test ## Batch C — Auth fail-closed tightening (R11) -- [ ] **C1** Add production-first fail-closed guard in `requireSession()` +- [~] **C1** Add production-first fail-closed guard in `requireSession()` - Description: Apply the two-line edit described in design § 3 ("Module boundaries — auth middleware"): at the very top of `requireSession`, return HTTP 503 when `isProduction(c.env)` is true and `getAuth()` yields a null/throwing result, regardless of the `ALLOW_AUTH_BYPASS` value. The non-production bypass path is unchanged. - Files: `apps/api/src/middleware/auth.ts` (edit). - Acceptance criteria: R11.2, R11.3, R11.4, R11.5. - Verification: existing test case `fails protected generation closed in production when auth is unavailable` stays green; the local-dev bypass path still works with the default `.env.example` values. - Dependencies: none (parallel with A and B). -- [ ] **C2** Add fail-closed contract + property tests `[pbt]` +- [~] **C2** Add fail-closed contract + property tests `[pbt]` - Description: Add the app-level contract tests for "admin 401 before any middleware", "CORS never wildcard in prod", and "prod auth 503 when Better Auth init throws" (design § 8). Add the fast-check suites for Properties 3 (CORS never wildcard), 4 (admin-key gating), and 5 (auth fail-closed in prod) since these target app-level behavior rather than a single rate-limit module. - Files: `apps/api/src/app.test.ts` (edit: add the three contract cases), `apps/api/src/app.pbt.test.ts` (new: holds Properties 3, 4, 5). - Acceptance criteria: R8.1, R10.3, R10.4, R11.4 (contract), R10.3, R8.1, R8.3, R8.4, R8.5, R8.6, R11.2, R11.3, R11.4 (properties). @@ -119,35 +119,35 @@ Design cross-references: [§ 2 Code layout](./design.md#code-layout), [§ 8 Test ## Batch D — Railway manifests + runbook scripts -- [ ] **D1** Add API service Railway manifest +- [~] **D1** Add API service Railway manifest - Description: Declare the Node 20 runtime, build/start commands, and `/health` healthcheck path for `stackfast-api` so `railway up` is deterministic regardless of Railway's autodetection. - Files: `apps/api/railway.toml` (new). - Acceptance criteria: R1.1, R5.1. - Verification: `railway up --service stackfast-api --dry-run` against a local Railway link (or a `railway config validate` equivalent) reports the manifest as valid. File is reviewed against ADR 003 § 1 and design § "API Service". - Dependencies: A6 (must not ship the manifest before the rate-limit module that the service will run). -- [ ] **D2** Add Web service Railway manifest +- [~] **D2** Add Web service Railway manifest - Description: Declare the static-hosting build and serve configuration for `stackfast-web` so the web service is redeployable by `railway up` with no manual dashboard fiddling. - Files: `apps/web/railway.toml` (new). - Acceptance criteria: R1.2. - Verification: manifest is reviewed against design § "Web Service"; `railway up --service stackfast-web --dry-run` reports valid. - Dependencies: B5 (web Sentry wiring must exist before the manifest declares the build that will upload source maps). -- [ ] **D3** Add migration one-shot script +- [~] **D3** Add migration one-shot script - Description: Add `scripts/deploy/migrate.ts` — a `tsx`-runnable wrapper around `drizzle-kit push` with a 30-second connection-retry loop per R2.3. Exits non-zero on any failure. - Files: `scripts/deploy/migrate.ts` (new). - Acceptance criteria: R2.3, R2.4, R2.5. - Verification: running `pnpm exec tsx scripts/deploy/migrate.ts --dry-run` against a local Neon branch prints the pending DDL (or "no changes"); forcing `DATABASE_URL` to an unreachable host causes the script to retry for ~30s before exiting non-zero; exit code is captured in the runbook. - Dependencies: none (parallel with D1, D2). -- [ ] **D4** Add post-deploy smoke script +- [~] **D4** Add post-deploy smoke script - Description: Add `scripts/deploy/smoke.ts` implementing the six assertions in design § 8 "Deploy smoke" — health, 31-req generation burst, 101-req read burst, admin 401, same-origin CORS ACAO, evil-origin ACAO absent. Exits 0/non-zero, prints a one-line JSON summary, and writes a timestamped report to `test-results/deploy-smoke-.json`. - Files: `scripts/deploy/smoke.ts` (new). - Acceptance criteria: R5.4, R6.1, R6.2, R6.3, R8.3, R10.2, R10.3. - Verification: run against the local dev server (`pnpm dev` + `pnpm exec tsx scripts/deploy/smoke.ts --base http://localhost:3000 --web http://localhost:5173`); all six assertions pass, the JSON summary lands in `test-results/`, and the script exits 0. A second run with the API stopped exits non-zero and the summary marks the health assertion as failed. - Dependencies: A6 (rate limiter wired in so R6.1/R6.3 are testable), C1 (admin + CORS behavior finalized), D3 (so `migrate` → `smoke` order is clear in the runbook). -- [ ] **D5** Add rollback runbook `[docs]` +- [~] **D5** Add rollback runbook `[docs]` - Description: Document `railway rollback --service stackfast-api` and `railway rollback --service stackfast-web`, the two-phase schema-compatibility rule from design § "Rollback, observability, and runbook notes", and the manual intervention point from R12.4. - Files: `scripts/deploy/rollback.md` (new). - Acceptance criteria: R12.1, R12.2, R12.3, R12.4, R12.5, R12.6, R12.7. @@ -158,14 +158,14 @@ Design cross-references: [§ 2 Code layout](./design.md#code-layout), [§ 8 Test ## Batch E — `.env.example` and README `[docs]` -- [ ] **E1** Extend `.env.example` with Phase 8 variables `[docs]` +- [~] **E1** Extend `.env.example` with Phase 8 variables `[docs]` - Description: Add the new rows from design § "Configuration surface" — `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN`, `RATE_LIMIT_BACKEND`, `SENTRY_DSN`, `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT_API`, `SENTRY_PROJECT_WEB`, `RAILWAY_GIT_COMMIT_SHA`, `VITE_SENTRY_DSN`, `VITE_APP_RELEASE`. Each row has a short comment linking to ADR 003 § 3 (Upstash) or § 5 (Sentry). - Files: `.env.example` (edit). - Acceptance criteria: R14.1. - Verification: reviewer diffs `.env.example` against design § "Configuration surface" and confirms every new row is present with a comment. - Dependencies: none (parallel with A–D). -- [ ] **E2** Add production-deployment section to the README `[docs]` +- [~] **E2** Add production-deployment section to the README `[docs]` - Description: Add the Railway CLI deploy flow (steps 1-9 from design § "End-to-end `railway link` → deployed story"), the Drizzle one-shot migration command, the per-service rollback commands, and the full production env var table. Link to ADR 001, ADR 002, and ADR 003. - Files: `readme.md` (edit). - Acceptance criteria: R14.1, R14.2, R14.3, R14.4, R14.5. @@ -176,56 +176,56 @@ Design cross-references: [§ 2 Code layout](./design.md#code-layout), [§ 8 Test ## Batch F — External provisioning `[external]` -- [ ] **F1** Provision Railway project + production and staging environments `[external]` +- [~] **F1** Provision Railway project + production and staging environments `[external]` - Description: Create (or link to) the `stackfast` Railway project; create `production` and `staging` Railway environments inside it; link the repo from the operator's workstation with `railway link`. - Preconditions: Railway account, Railway CLI installed and `railway login` completed, operator has project-create permission in the target team/workspace. - Acceptance criteria: R1.3, R1.4, R13.1. - Verification: operator records the project id, environment names, and the output of `railway status` (showing both services attached to both environments) in the deploy log. No code lands. - Dependencies: E1, E2 (docs must match the variables the operator is about to set). -- [ ] **F2** Provision Neon production branch `[external]` +- [~] **F2** Provision Neon production branch `[external]` - Description: Confirm the Neon Postgres project exists (pre-existing per ADR 003 § 2); select the `main` branch as the Neon Production Branch; retrieve the pooled connection string. - Preconditions: Neon account, existing Neon project. - Acceptance criteria: R2.1, R2.2. - Verification: operator records the Neon project id and the pooled connection string's host-only fragment (never the full secret) in the deploy log. - Dependencies: F1. -- [ ] **F3** Provision Neon staging branch `[external]` +- [~] **F3** Provision Neon staging branch `[external]` - Description: Create a branch named `staging` off `main`; enable aggressive auto-suspend; retrieve the staging pooled connection string. - Preconditions: F2. - Acceptance criteria: R13.2. - Verification: operator records the staging branch name and host-only fragment; `neon branches list` shows both branches. - Dependencies: F2. -- [ ] **F4** Provision Upstash Redis (production + staging) `[external]` +- [~] **F4** Provision Upstash Redis (production + staging) `[external]` - Description: Create two Upstash Redis databases — one named `stackfast-prod`, one `stackfast-staging`. Copy `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` for each into the operator's secrets store. - Preconditions: Upstash account. - Acceptance criteria: R4.6, R13.4. - Verification: operator records database ids and region; the tokens themselves are stored only in Railway env vars (F→G/H tasks). - Dependencies: F1. -- [ ] **F5** Provision Sentry projects (API + web) `[external]` +- [~] **F5** Provision Sentry projects (API + web) `[external]` - Description: Create two Sentry projects — `stackfast-api` (Node) and `stackfast-web` (React). Generate an org-scoped auth token for source-map upload. Record DSNs for each project and each environment (prod and staging). - Preconditions: Sentry account, Sentry org chosen. - Acceptance criteria: R7.1, R7.2. - Verification: operator records project slugs, DSNs (host-only fragment), and the auth-token scope in the deploy log. DSNs are stored only in Railway env vars. - Dependencies: F1. -- [ ] **F6** Register Production GitHub OAuth app `[external]` +- [~] **F6** Register Production GitHub OAuth app `[external]` - Description: Create a GitHub OAuth application with callback URL `https://api.stackfast.app/api/auth/callback/github`. Copy `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`. - Preconditions: GitHub account with permission to register OAuth apps in the target org/personal account. - Acceptance criteria: R3.1. - Verification: operator records the OAuth app name, client id, and the registered callback URL; client secret is stored only in Railway env vars. - Dependencies: F1. -- [ ] **F7** Register Staging GitHub OAuth app `[external]` +- [~] **F7** Register Staging GitHub OAuth app `[external]` - Description: Create a second GitHub OAuth application distinct from F6 with callback URL matching the staging API host (`https://api.staging.stackfast.app/api/auth/callback/github`). Copy its own client id and secret. - Preconditions: same as F6. - Acceptance criteria: R13.3. - Verification: operator records the staging app name, client id, callback URL; the secret is stored only in Railway staging env vars. - Dependencies: F1. -- [ ] **F8** Configure DNS and attach custom domains `[external]` +- [~] **F8** Configure DNS and attach custom domains `[external]` - Description: Point `stackfast.app` at the Web Service and `api.stackfast.app` at the API Service (production). Repeat for `staging.stackfast.app` and `api.staging.stackfast.app`. Attach the domains to the corresponding Railway services; confirm TLS certificate issuance; verify HTTP→HTTPS redirect. - Preconditions: F1, domain owner access, DNS management access for `stackfast.app`. - Acceptance criteria: R9.1, R9.2, R9.3, R9.4, R9.5. @@ -236,42 +236,42 @@ Design cross-references: [§ 2 Code layout](./design.md#code-layout), [§ 8 Test ## Batch G — Staging cutover -- [ ] **G1** Set staging environment variables in Railway `[external]` +- [~] **G1** Set staging environment variables in Railway `[external]` - Description: Set every variable in the "Staging" column of design § "Configuration surface" on both `stackfast-api` and `stackfast-web` in the Railway staging environment. Leave `RATE_LIMIT_BACKEND` at its default (`memory`) for this first deploy; leave `SENTRY_DSN` optional. - Preconditions: F1, F3, F4, F5, F7. - Acceptance criteria: R3.2, R3.5, R11.1, R13.2, R13.3, R13.4, R13.5. - Verification: `railway variables list --service stackfast-api --environment staging` reports every required variable (operator redacts secrets); `ALLOW_AUTH_BYPASS=false`. - Dependencies: F1, F3, F4, F5, F7. -- [ ] **G2** Deploy API and Web to staging `[external]` +- [~] **G2** Deploy API and Web to staging `[external]` - Description: `railway up --service stackfast-api --environment staging` and `railway up --service stackfast-web --environment staging`. Confirm both services reach healthy state. - Preconditions: D1, D2, G1. - Acceptance criteria: R1.1, R1.2, R1.4, R5.1, R5.2. - Verification: operator records the two `railway up` build ids; `GET https://api.staging.stackfast.app/health` returns 200 `OK` within 15 seconds of the container marking ready. - Dependencies: D1, D2, G1. -- [ ] **G3** Run migrations against Neon staging branch `[external]` +- [~] **G3** Run migrations against Neon staging branch `[external]` - Description: Execute `railway run --service stackfast-api --environment staging -- pnpm exec tsx scripts/deploy/migrate.ts`. Capture stdout/stderr. - Preconditions: D3, F3, G2. - Acceptance criteria: R2.4, R2.5. - Verification: script exits 0; output is attached to the deploy log; Neon staging branch reflects the expected schema. - Dependencies: D3, F3, G2. -- [ ] **G4** Flip `RATE_LIMIT_BACKEND=upstash` in staging `[external]` +- [~] **G4** Flip `RATE_LIMIT_BACKEND=upstash` in staging `[external]` - Description: Set `RATE_LIMIT_BACKEND=upstash`, `UPSTASH_REDIS_REST_URL`, and `UPSTASH_REDIS_REST_TOKEN` on the staging API service. Railway restarts the instance automatically. - Preconditions: A6 (factory reads the flag), F4, G2. - Acceptance criteria: R4.1, R4.6, design § 9 step 3. - Verification: `railway logs --service stackfast-api --environment staging` shows no `[rate-limit] upstash unavailable` entries in the minute after restart. - Dependencies: A6, F4, G2. -- [ ] **G5** Run deploy smoke + Playwright E2E against staging `[external]` +- [~] **G5** Run deploy smoke + Playwright E2E against staging `[external]` - Description: Run `pnpm exec tsx scripts/deploy/smoke.ts --base https://api.staging.stackfast.app --web https://staging.stackfast.app` and `E2E_BASE_URL=https://staging.stackfast.app E2E_API_URL=https://api.staging.stackfast.app/api/v1 pnpm test:e2e`. File the resulting `test-results/deploy-smoke-*.json` in the deploy log. - Preconditions: D4, G3, G4. - Acceptance criteria: R5.4, R6.1, R6.2, R6.3, R8.3, R10.2, R10.3, R3.8. - Verification: smoke script exits 0 and the JSON report shows six passing assertions; Playwright reports all specs green against the staging origins. - Dependencies: D4, G3, G4. -- [ ] **G6** Soak the rate-limit properties against real Upstash `[external] [pbt]` +- [~] **G6** Soak the rate-limit properties against real Upstash `[external] [pbt]` - Description: Re-run the rate-limit property-based suite with `RATE_LIMIT_BACKEND=upstash` and the real staging Upstash credentials in the local environment — design § 9 step 3 calls out confirming Property 1 holds end-to-end. The test harness surfaces its property-testing warning on this run. - Preconditions: A5, F4, G4. - Acceptance criteria: R4.5 (end-to-end against the real backend), design § 9 step 3. @@ -282,49 +282,49 @@ Design cross-references: [§ 2 Code layout](./design.md#code-layout), [§ 8 Test ## Batch H — Production cutover -- [ ] **H1** Set production environment variables in Railway `[external]` +- [~] **H1** Set production environment variables in Railway `[external]` - Description: Set every variable in the "Prod" column of design § "Configuration surface" on both services. Leave `RATE_LIMIT_BACKEND=memory` for the first deploy — the flag flip happens after the smoke in H6. Set `ALLOW_AUTH_BYPASS=false`. - Preconditions: F1, F2, F4, F5, F6. - Acceptance criteria: R3.2, R3.5, R8.2, R10.1, R11.1. - Verification: `railway variables list --service stackfast-api --environment production` shows every required var; admin and auth secrets are distinct from `BETTER_AUTH_SECRET`. - Dependencies: F1, F2, F4, F5, F6. -- [ ] **H2** Deploy API and Web to production `[external]` +- [~] **H2** Deploy API and Web to production `[external]` - Description: `railway up --service stackfast-api --environment production` and `railway up --service stackfast-web --environment production`. - Preconditions: D1, D2, G5 (staging must have fully passed), H1. - Acceptance criteria: R1.1, R1.2, R1.4. - Verification: both services report healthy; operator records the production build ids. - Dependencies: D1, D2, G5, H1. -- [ ] **H3** Run migrations against Neon production branch `[external]` +- [~] **H3** Run migrations against Neon production branch `[external]` - Description: Execute `railway run --service stackfast-api --environment production -- pnpm exec tsx scripts/deploy/migrate.ts`. Capture stdout/stderr. Do not re-run on rollback. - Preconditions: D3, F2, H2. - Acceptance criteria: R2.4, R2.5, R2.8. - Verification: script exits 0; output attached to the deploy log; Neon production branch schema matches the expected state. - Dependencies: D3, F2, H2. -- [ ] **H4** Confirm DNS cutover for production `[external]` +- [~] **H4** Confirm DNS cutover for production `[external]` - Description: Ensure `stackfast.app` and `api.stackfast.app` resolve to the Railway edge, TLS is healthy, and the HTTP→HTTPS redirect fires. If F8 already flipped DNS, verify it here; otherwise flip now. - Preconditions: F8, H2. - Acceptance criteria: R9.1, R9.2, R9.3, R9.4, R9.5. - Verification: `dig api.stackfast.app +short` matches the Railway edge; `curl -I https://stackfast.app` returns 200; `curl -I http://stackfast.app` returns 301/308 to `https://stackfast.app`. - Dependencies: F8, H2. -- [ ] **H5** Run production smoke and record `/health` evidence `[external]` +- [~] **H5** Run production smoke and record `/health` evidence `[external]` - Description: Run `pnpm exec tsx scripts/deploy/smoke.ts --base https://api.stackfast.app --web https://stackfast.app`. Operator attaches `test-results/deploy-smoke-.json` to the deploy PR and records the `/health` status + body in the deploy log per R5.4. - Preconditions: D4, H3, H4. - Acceptance criteria: R5.4, R8.3, R10.2, R10.3. - Verification: smoke exits 0 with the six assertions passing; `GET https://api.stackfast.app/health` → `200 OK` captured in the deploy log. - Dependencies: D4, H3, H4. -- [ ] **H6** Flip `RATE_LIMIT_BACKEND=upstash` in production `[external]` +- [~] **H6** Flip `RATE_LIMIT_BACKEND=upstash` in production `[external]` - Description: Set `RATE_LIMIT_BACKEND=upstash` plus `UPSTASH_REDIS_REST_URL` / `_TOKEN` on the production API service. Railway restarts the instance. Per design § 9 step 4 this is reversible by flipping the flag back to `memory`. - Preconditions: F4, H5. - Acceptance criteria: R4.1, R4.6, design § 9 step 4. - Verification: post-restart `railway logs` show no fail-open warning in the first minute; a single `GET /api/v1/tools/search` round-trip responds 200. - Dependencies: F4, H5. -- [ ] **H7** Verify production rate limiting end-to-end `[external]` +- [~] **H7** Verify production rate limiting end-to-end `[external]` - Description: Re-run the smoke's rate-limit assertions against the production origins to confirm the Upstash path behaves identically to staging. Assert R6.1–R6.3 specifically against production. - Preconditions: D4, H6. - Acceptance criteria: R6.1, R6.2, R6.3, R6.4. @@ -335,21 +335,21 @@ Design cross-references: [§ 2 Code layout](./design.md#code-layout), [§ 8 Test ## Batch I — Post-deploy cleanup -- [ ] **I1** Post-deploy verification of dead-code removal +- [~] **I1** Post-deploy verification of dead-code removal - Description: Confirm, after production has been running on `RATE_LIMIT_BACKEND=upstash`, that the `setInterval` cleanup removed in A6 is not being reintroduced and that `rateLimitBuckets` is not imported anywhere outside the memory backend and its tests. Purely a verification task — the removal itself landed in A6. - Files: none edited; verification only. - Acceptance criteria: design § 9 step 1. - Verification: `grep -R "rateLimitBuckets" apps/api/src` returns only matches under `apps/api/src/rate-limit/memory*`; `grep -R "setInterval" apps/api/src/index.ts` returns no matches; Railway production logs show no `[rate-limit] Cleaned` lines since H6. - Dependencies: H7. -- [ ] **I2** Drop memory-mode rows from production-facing docs and configs `[docs]` +- [~] **I2** Drop memory-mode rows from production-facing docs and configs `[docs]` - Description: Remove any remaining `RATE_LIMIT_BACKEND=memory` example rows from the production column of `.env.example` and the README production section. The memory backend stays in the codebase for tests (design § 9 step 5) — only the prod-facing docs are trimmed. - Files: `.env.example` (edit), `readme.md` (edit). - Acceptance criteria: R14.1, design § 9 step 5. - Verification: reviewer diffs the updated docs and confirms the production column lists `RATE_LIMIT_BACKEND=upstash` unambiguously. - Dependencies: I1. -- [ ] **I3** Tick the ROADMAP Phase 8 checkbox `[docs]` +- [~] **I3** Tick the ROADMAP Phase 8 checkbox `[docs]` - Description: Mark Phase 8 deployment complete in `ROADMAP.md`; add a one-line entry to `CHANGELOG.md` summarizing the cutover. - Files: `ROADMAP.md` (edit), `CHANGELOG.md` (edit if present, otherwise create a root-level entry). - Acceptance criteria: closes the Phase 8 deliverable per ADR 003 § "Implementation notes". diff --git a/apps/api/package.json b/apps/api/package.json index 5fc2493..353a144 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,6 +21,8 @@ "@stackfast/rules-engine": "workspace:*", "@stackfast/schemas": "workspace:*", "@stackfast/shared": "workspace:*", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "better-auth": "^1.1.13", "drizzle-orm": "^0.45.2", "hono": "^4.6.1", @@ -28,6 +30,7 @@ }, "devDependencies": { "@types/node": "^22.10.2", + "fast-check": "^4.8.0", "tsx": "^4.19.2", "typescript": "^5.6.3" } diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index eaa1973..9c5c4ae 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import app from "./app.js"; +import { BUCKETS } from "./rate-limit/buckets.js"; +import { + __resetBackendForTests, +} from "./rate-limit/index.js"; +import type { + RateLimitBackend, + RateLimitCheckArgs, + RateLimitDecision, +} from "./rate-limit/types.js"; describe("api", () => { it("returns health status", async () => { @@ -458,4 +467,203 @@ describe("api", () => { }); expect(response.headers.get("X-Request-ID")).toBe(customId); }); + + // ─── A6 rate-limit contract tests ────────────────────────────── + // + // These four cases are named verbatim in design.md § 8 "Testing strategy" + // and pin down the behavior the factory rewrite in + // `apps/api/src/rate-limit/index.ts` has to preserve. Every case is + // self-isolating: each one installs its own backend via + // `__resetBackendForTests` and restores the default in `afterEach` so a + // failing case cannot bleed counters into the next. + + describe("rate-limit contract", () => { + afterEach(() => { + __resetBackendForTests(null); + }); + + /** + * Build a `RateLimitBackend` that records every `check()` call it + * receives into the returned `calls` array. Always returns an allow + * decision with full remaining quota so the admin/exempt-route checks + * can focus on "was this called at all?". + */ + function createSpyBackend(): { + backend: RateLimitBackend; + calls: RateLimitCheckArgs[]; + } { + const calls: RateLimitCheckArgs[] = []; + const backend: RateLimitBackend = { + name: "memory", + async check(args: RateLimitCheckArgs): Promise { + calls.push(args); + return { + allowed: true, + remaining: BUCKETS[args.bucket].limit, + limit: BUCKETS[args.bucket].limit, + resetAtEpochMs: Date.now() + BUCKETS[args.bucket].windowMs, + }; + }, + }; + return { backend, calls }; + } + + /** + * Build a `RateLimitBackend` that delegates to a shared Map. Used by + * the "bucket count survives backend swap" case to prove that two + * wrapper instances sharing the same underlying state keep accounting + * consistent across a simulated restart. Semantics mirror the real + * memory backend: count is incremented on every call, `allowed` is + * `count <= limit`, and the window resets lazily at `resetAtEpochMs`. + */ + function createSharedStateBackend( + store: Map, + ): RateLimitBackend { + return { + name: "upstash", + async check({ bucket, clientId }): Promise { + const key = `${bucket}:${clientId}`; + const config = BUCKETS[bucket]; + const now = Date.now(); + let entry = store.get(key); + if (!entry || now >= entry.resetAtEpochMs) { + entry = { count: 1, resetAtEpochMs: now + config.windowMs }; + store.set(key, entry); + } else { + entry.count += 1; + } + return { + allowed: entry.count <= config.limit, + remaining: Math.max(0, config.limit - entry.count), + limit: config.limit, + resetAtEpochMs: entry.resetAtEpochMs, + }; + }, + }; + } + + it("admin 401 before rate-limit counter increments (R8.1)", async () => { + const { backend, calls } = createSpyBackend(); + __resetBackendForTests(backend); + + const response = await app.request("/admin/tools/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tools: [{ id: "whatever" }] }), + }); + + expect(response.status).toBe(401); + // The admin middleware rejects before any downstream middleware runs. + // `/admin/*` also isn't under `/api/v1/*`, so the rate-limit + // middleware cannot match it — the backend is never consulted. + expect(calls).toHaveLength(0); + }); + + it("Retry-After only on 429 (R4.7, R4.8)", async () => { + const store = new Map(); + __resetBackendForTests(createSharedStateBackend(store)); + + const clientId = "retry-after-test"; + const readLimit = BUCKETS.read.limit; + + // Requests 1..limit should all be 200 and MUST NOT include a + // Retry-After header. We collect the Retry-After values to assert + // they are all null in one shot. + const retryAfterDuringAllowed: (string | null)[] = []; + for (let i = 0; i < readLimit; i += 1) { + const ok = await app.request("/api/v1/tools/search", { + headers: { "x-forwarded-for": clientId }, + }); + expect(ok.status).toBe(200); + retryAfterDuringAllowed.push(ok.headers.get("Retry-After")); + } + expect(retryAfterDuringAllowed.every((v) => v === null)).toBe(true); + + // Request limit+1 trips the rate limit: must return 429 and a + // positive-integer Retry-After header. + const blocked = await app.request("/api/v1/tools/search", { + headers: { "x-forwarded-for": clientId }, + }); + expect(blocked.status).toBe(429); + const retryAfter = blocked.headers.get("Retry-After"); + expect(retryAfter).not.toBeNull(); + const seconds = Number(retryAfter); + expect(Number.isInteger(seconds)).toBe(true); + expect(seconds).toBeGreaterThan(0); + }); + + it("exempt routes never counted (R4.9)", async () => { + const { backend, calls } = createSpyBackend(); + __resetBackendForTests(backend); + + for (let i = 0; i < 5; i += 1) { + const health = await app.request("/health"); + expect(health.status).toBe(200); + } + for (let i = 0; i < 5; i += 1) { + const openapi = await app.request("/openapi.json"); + expect(openapi.status).toBe(200); + } + + expect(calls).toHaveLength(0); + }); + + it("bucket count survives backend swap (R6.4)", async () => { + const store = new Map(); + __resetBackendForTests(createSharedStateBackend(store)); + + const clientId = "backend-swap-test"; + const generationLimit = BUCKETS.generation.limit; + const preSwap = generationLimit - 10; // 20 of the 30-quota window + + // Fire `preSwap` generation-bucket requests. These exercise both + // rate-limit middlewares (generation + /api/v1/*), so we just look + // at the generation counter in the shared store. + for (let i = 0; i < preSwap; i += 1) { + const response = await app.request("/api/v1/blueprints", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-forwarded-for": clientId, + }, + // Empty body intentionally: we want the rate-limit middleware + // to count every request, but the blueprint handler to short- + // circuit quickly with a 400 so the test stays fast. + body: "{}", + }); + expect(response.status).not.toBe(429); + } + expect( + store.get(`generation:${clientId}`)?.count, + ).toBe(preSwap); + + // Swap the backend wrapper but keep the SAME shared store — this + // simulates an API service restart where the underlying Redis + // keyspace survives. If accounting resets, the next 11 requests + // would all be 200 and the invariant breaks. + __resetBackendForTests(createSharedStateBackend(store)); + + const postSwapStatuses: number[] = []; + for (let i = 0; i < 11; i += 1) { + const response = await app.request("/api/v1/blueprints", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-forwarded-for": clientId, + }, + body: "{}", + }); + postSwapStatuses.push(response.status); + } + + // 20 pre-swap + first 10 post-swap = 30 (still allowed). Request 31 + // (the 11th post-swap) must be 429 because the shared store tracks + // the cumulative count across the restart. + expect(postSwapStatuses.slice(0, 10).every((s) => s !== 429)).toBe(true); + expect(postSwapStatuses[10]).toBe(429); + expect( + store.get(`generation:${clientId}`)?.count, + ).toBe(generationLimit + 1); + }); + }); }); \ No newline at end of file diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index bf97c24..e819117 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -18,6 +18,7 @@ import type { MiddlewareHandler } from "hono/types"; import { z } from "zod"; import { openApiDocument } from "./openapi.js"; import { getAuth, requireSession, optionalSession } from "./middleware/auth.js"; +import { createRateLimitMiddleware } from "./rate-limit/index.js"; type Bindings = { ADMIN_API_KEY?: string; @@ -52,11 +53,6 @@ const EnrichToolSchema = z.object({ force: z.boolean().optional(), }); -const GENERATION_LIMIT = 30; -const READ_LIMIT = 100; -const WINDOW_MS = 60_000; -export const rateLimitBuckets = new Map(); - const catalogLoader = new CatalogLoader(); const configuredCorsOrigin = process.env.CORS_ORIGIN ?? process.env.WEB_ORIGIN ?? "http://localhost:5173"; @@ -94,9 +90,12 @@ app.use("*", async (c, next) => { }); // --- Rate limiting --- -app.use("/api/v1/blueprints", rateLimit("generation", GENERATION_LIMIT)); -app.use("/api/v1/scaffolds", rateLimit("generation", GENERATION_LIMIT)); -app.use("/api/v1/*", rateLimit("read", READ_LIMIT)); +// `/health` and `/openapi.json` are registered as top-level routes without any +// `/api/v1/*` prefix, so the rate-limit middleware below never matches them +// (R4.9: exempt routes never counted). +app.use("/api/v1/blueprints", createRateLimitMiddleware("generation")); +app.use("/api/v1/scaffolds", createRateLimitMiddleware("generation")); +app.use("/api/v1/*", createRateLimitMiddleware("read")); // --- Auth middleware --- app.use("/api/v1/blueprints", requireSession()); @@ -346,29 +345,6 @@ function resolveTools(toolIds: string[]): Tool[] { return tools as Tool[]; } -function rateLimit(bucket: string, limit: number): MiddlewareHandler<{ Bindings: Bindings; Variables: Variables }> { - return async (c, next) => { - const clientId = c.req.header("x-forwarded-for") ?? c.req.header("cf-connecting-ip") ?? "local"; - const key = `${bucket}:${clientId}`; - const now = Date.now(); - const current = rateLimitBuckets.get(key); - - if (!current || current.resetAt <= now) { - rateLimitBuckets.set(key, { count: 1, resetAt: now + WINDOW_MS }); - await next(); - return; - } - - if (current.count >= limit) { - c.header("Retry-After", String(Math.ceil((current.resetAt - now) / 1000))); - return c.json({ error: "Rate limit exceeded", requestId: c.get("requestId") }, 429); - } - - current.count += 1; - await next(); - }; -} - function requireAdminApiKey(): MiddlewareHandler<{ Bindings: Bindings; Variables: Variables }> { return async (c, next) => { const configuredKey = c.env?.ADMIN_API_KEY ?? process.env.ADMIN_API_KEY; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a8ccf7e..c386bfb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,5 @@ import { serve } from "@hono/node-server"; -import app, { rateLimitBuckets } from "./app.js"; +import app from "./app.js"; const port = Number(process.env.PORT ?? 3000); @@ -10,21 +10,4 @@ serve({ console.log(`Stackfast API listening on http://localhost:${port}`); -// --- Rate limit stale key cleanup (in-memory, single-process) --- -// TODO Phase 8: Replace with Upstash/Redis-backed rate limiting for multi-instance support -const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60_000; // every 5 minutes -setInterval(() => { - const now = Date.now(); - let cleaned = 0; - for (const [key, bucket] of rateLimitBuckets) { - if (bucket.resetAt <= now) { - rateLimitBuckets.delete(key); - cleaned++; - } - } - if (cleaned > 0) { - console.log(`[rate-limit] Cleaned ${cleaned} stale bucket(s)`); - } -}, RATE_LIMIT_CLEANUP_INTERVAL_MS); - export default app; diff --git a/apps/api/src/rate-limit/buckets.test.ts b/apps/api/src/rate-limit/buckets.test.ts new file mode 100644 index 0000000..cb2150c --- /dev/null +++ b/apps/api/src/rate-limit/buckets.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { BUCKETS, BUCKET_NAMES, type BucketName } from "./buckets.js"; + +describe("rate-limit buckets", () => { + it("configures the generation bucket at 30 requests per 60s (R4.2)", () => { + expect(BUCKETS.generation).toEqual({ limit: 30, windowMs: 60_000 }); + }); + + it("configures the read bucket at 100 requests per 60s (R4.3)", () => { + expect(BUCKETS.read).toEqual({ limit: 100, windowMs: 60_000 }); + }); + + it("exposes exactly the two bucket names used by the middleware", () => { + expect(BUCKET_NAMES).toEqual(["generation", "read"]); + const keys = Object.keys(BUCKETS) as BucketName[]; + expect(new Set(keys)).toEqual(new Set(["generation", "read"])); + }); + + it("uses a 60 second window for every bucket", () => { + for (const name of BUCKET_NAMES) { + expect(BUCKETS[name].windowMs).toBe(60_000); + } + }); +}); diff --git a/apps/api/src/rate-limit/buckets.ts b/apps/api/src/rate-limit/buckets.ts new file mode 100644 index 0000000..b029dc1 --- /dev/null +++ b/apps/api/src/rate-limit/buckets.ts @@ -0,0 +1,27 @@ +/** + * Rate-limit bucket configuration. + * + * Two buckets per R4.2 / R4.3: + * - `generation` — 30 requests per 60s, applied to POST /api/v1/blueprints + * and POST /api/v1/scaffolds. + * - `read` — 100 requests per 60s, applied to the remaining /api/v1/* + * routes. + * + * Kept as a pure module so both backends (memory and Upstash, landing in A2 + * and A4) and the fail-open wrapper (A3) can import it without pulling in + * Hono, Redis, or any other runtime dependency. + */ + +export type BucketName = "generation" | "read"; + +export interface BucketConfig { + readonly limit: number; + readonly windowMs: number; +} + +export const BUCKETS: Readonly> = { + generation: { limit: 30, windowMs: 60_000 }, + read: { limit: 100, windowMs: 60_000 }, +} as const; + +export const BUCKET_NAMES: readonly BucketName[] = ["generation", "read"] as const; diff --git a/apps/api/src/rate-limit/client-id.test.ts b/apps/api/src/rate-limit/client-id.test.ts new file mode 100644 index 0000000..337dd57 --- /dev/null +++ b/apps/api/src/rate-limit/client-id.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { resolveClientId } from "./client-id.js"; + +describe("resolveClientId (R4.4)", () => { + it("prefers x-forwarded-for over cf-connecting-ip and local", () => { + const id = resolveClientId({ + "x-forwarded-for": "203.0.113.7", + "cf-connecting-ip": "198.51.100.9", + }); + + expect(id).toBe("203.0.113.7"); + }); + + it("falls back to cf-connecting-ip when x-forwarded-for is absent", () => { + const id = resolveClientId({ "cf-connecting-ip": "198.51.100.9" }); + + expect(id).toBe("198.51.100.9"); + }); + + it('falls back to "local" when neither header is present', () => { + expect(resolveClientId({})).toBe("local"); + }); + + it('falls back to "local" when both headers are empty strings', () => { + const id = resolveClientId({ + "x-forwarded-for": "", + "cf-connecting-ip": "", + }); + + expect(id).toBe("local"); + }); + + it("takes the left-most IP when x-forwarded-for is a comma-separated chain", () => { + const id = resolveClientId({ + "x-forwarded-for": "203.0.113.7, 198.51.100.9, 10.0.0.1", + }); + + expect(id).toBe("203.0.113.7"); + }); + + it("trims whitespace around the left-most IP", () => { + const id = resolveClientId({ + "x-forwarded-for": " 203.0.113.7 , 198.51.100.9 ", + }); + + expect(id).toBe("203.0.113.7"); + }); + + it("skips an empty left-most segment and falls through to cf-connecting-ip", () => { + const id = resolveClientId({ + "x-forwarded-for": ", 198.51.100.9", + "cf-connecting-ip": "cf-fallback", + }); + + expect(id).toBe("cf-fallback"); + }); + + it("reads headers from a Headers instance", () => { + const headers = new Headers(); + headers.set("x-forwarded-for", "203.0.113.42, 10.0.0.1"); + + expect(resolveClientId(headers)).toBe("203.0.113.42"); + }); + + it("reads headers through a getter function (Hono-style)", () => { + const get = (name: string): string | null => { + if (name === "x-forwarded-for") return null; + if (name === "cf-connecting-ip") return "198.51.100.9"; + return null; + }; + + expect(resolveClientId(get)).toBe("198.51.100.9"); + }); + + it("matches headers case-insensitively on plain records", () => { + const id = resolveClientId({ "X-Forwarded-For": "203.0.113.7" }); + + expect(id).toBe("203.0.113.7"); + }); +}); diff --git a/apps/api/src/rate-limit/client-id.ts b/apps/api/src/rate-limit/client-id.ts new file mode 100644 index 0000000..fbc9daf --- /dev/null +++ b/apps/api/src/rate-limit/client-id.ts @@ -0,0 +1,70 @@ +/** + * Client-id resolution for the rate limiter (R4.4). + * + * Header precedence: + * 1. `x-forwarded-for` — when present, use the left-most IP (comma-split, + * trimmed). Proxies append the chain right-ward, so the first entry is + * the originating client. + * 2. `cf-connecting-ip` — Cloudflare's single-IP header; used verbatim. + * 3. the literal string `"local"` — fallback when no client-id header is + * present (covers loopback requests and local dev). + * + * Pure function. No side effects, no Hono import — takes whatever header bag + * the caller wants to pass in. Callers can pass either Hono's `c.req` header + * accessor result, a `Headers` instance, or a plain record. + */ + +export type HeaderLookup = + | Headers + | Record + | ((name: string) => string | null | undefined); + +const XFF_HEADER = "x-forwarded-for"; +const CF_HEADER = "cf-connecting-ip"; +const FALLBACK = "local"; + +export function resolveClientId(headers: HeaderLookup): string { + const xff = readHeader(headers, XFF_HEADER); + if (xff) { + const leftMost = xff.split(",")[0]?.trim(); + if (leftMost) { + return leftMost; + } + } + + const cf = readHeader(headers, CF_HEADER); + if (cf) { + const trimmed = cf.trim(); + if (trimmed) { + return trimmed; + } + } + + return FALLBACK; +} + +function readHeader(headers: HeaderLookup, name: string): string | undefined { + if (typeof headers === "function") { + const value = headers(name); + return typeof value === "string" ? value : undefined; + } + + if (headers instanceof Headers) { + const value = headers.get(name); + return value ?? undefined; + } + + // Plain record — match the key case-insensitively, as HTTP headers allow. + const record = headers as Record; + const target = name.toLowerCase(); + for (const key of Object.keys(record)) { + if (key.toLowerCase() === target) { + const value = record[key]; + if (Array.isArray(value)) { + return value[0]; + } + return value; + } + } + return undefined; +} diff --git a/apps/api/src/rate-limit/fail-open.test.ts b/apps/api/src/rate-limit/fail-open.test.ts new file mode 100644 index 0000000..8fe965c --- /dev/null +++ b/apps/api/src/rate-limit/fail-open.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from "vitest"; +import { BUCKETS } from "./buckets.js"; +import { wrapFailOpen } from "./fail-open.js"; +import type { + RateLimitBackend, + RateLimitCheckArgs, + RateLimitDecision, +} from "./types.js"; + +/** + * Deterministic clock — same shape used by memory.test.ts. + */ +function makeClock(start = 1_700_000_000_000) { + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + set: (ms: number) => { + current = ms; + }, + }; +} + +/** + * Scriptable backend: each call consumes the next entry from `script`. + * - function → called with `args`, its return/throw becomes the result + * - "throw" → rejects with a default Error + * - "sync-throw" → throws synchronously (not a rejected promise) + * - otherwise → resolves with the literal decision + * + * Keeps the test harness explicit about what the inner backend does on + * each call so the log-gate / recovery paths can be driven precisely. + */ +type ScriptStep = + | RateLimitDecision + | "throw" + | "sync-throw" + | ((args: RateLimitCheckArgs) => Promise | RateLimitDecision); + +function makeScriptedBackend( + script: ScriptStep[], + name: RateLimitBackend["name"] = "upstash", +): { backend: RateLimitBackend; calls: RateLimitCheckArgs[] } { + const calls: RateLimitCheckArgs[] = []; + let index = 0; + const backend: RateLimitBackend = { + name, + check(args) { + calls.push(args); + if (index >= script.length) { + throw new Error(`scripted backend ran out of steps at call #${index + 1}`); + } + const step = script[index++]; + if (step === "throw") { + return Promise.reject(new Error("upstash 503")); + } + if (step === "sync-throw") { + throw new Error("synchronous boom"); + } + if (typeof step === "function") { + const result = step(args); + return Promise.resolve(result); + } + return Promise.resolve(step); + }, + }; + return { backend, calls }; +} + +function okDecision(bucket: RateLimitCheckArgs["bucket"], nowMs: number): RateLimitDecision { + return { + allowed: true, + remaining: BUCKETS[bucket].limit - 1, + limit: BUCKETS[bucket].limit, + resetAtEpochMs: nowMs + BUCKETS[bucket].windowMs, + }; +} + +describe("wrapFailOpen", () => { + it("passes a successful decision through unchanged and does not log (Validates: Requirements R4.5)", async () => { + const clock = makeClock(); + const logger = vi.fn(); + const decision = okDecision("generation", clock.now()); + const { backend } = makeScriptedBackend([decision]); + + const wrapped = wrapFailOpen(backend, { now: clock.now, logger }); + const out = await wrapped.check({ bucket: "generation", clientId: "alice" }); + + expect(out).toEqual(decision); + expect(logger).not.toHaveBeenCalled(); + }); + + it("allows the request when the inner backend rejects and returns a bucket-sized synthetic decision (Validates: Requirements R4.5)", async () => { + const clock = makeClock(); + const logger = vi.fn(); + const { backend } = makeScriptedBackend(["throw"]); + + const wrapped = wrapFailOpen(backend, { now: clock.now, logger }); + const out = await wrapped.check({ bucket: "generation", clientId: "alice" }); + + expect(out.allowed).toBe(true); + expect(out.limit).toBe(BUCKETS.generation.limit); + expect(out.remaining).toBe(BUCKETS.generation.limit); + expect(out.resetAtEpochMs).toBe(clock.now() + BUCKETS.generation.windowMs); + }); + + it("logs exactly once across multiple failures inside a 60 s window (Validates: Requirements R4.5)", async () => { + const clock = makeClock(); + const logger = vi.fn(); + const { backend } = makeScriptedBackend(["throw", "throw", "throw", "throw"]); + + const wrapped = wrapFailOpen(backend, { now: clock.now, logger }); + + await wrapped.check({ bucket: "generation", clientId: "alice" }); + clock.advance(10_000); + await wrapped.check({ bucket: "generation", clientId: "alice" }); + clock.advance(20_000); + await wrapped.check({ bucket: "read", clientId: "bob" }); + clock.advance(29_999); // still inside the first 60 s window (total 59_999 ms) + await wrapped.check({ bucket: "read", clientId: "bob" }); + + const matching = logger.mock.calls.filter( + (call) => + typeof call[0] === "string" && + (call[0] as string).startsWith("[rate-limit] upstash unavailable"), + ); + expect(matching).toHaveLength(1); + }); + + it("logs again once a full 60 s has elapsed since the last log line (Validates: Requirements R4.5)", async () => { + const clock = makeClock(); + const logger = vi.fn(); + const { backend } = makeScriptedBackend(["throw", "throw", "throw"]); + + const wrapped = wrapFailOpen(backend, { now: clock.now, logger }); + + await wrapped.check({ bucket: "generation", clientId: "alice" }); // logs + clock.advance(30_000); + await wrapped.check({ bucket: "generation", clientId: "alice" }); // suppressed + clock.advance(30_000); // total 60_000 ms since first log + await wrapped.check({ bucket: "generation", clientId: "alice" }); // logs again + + const matching = logger.mock.calls.filter( + (call) => + typeof call[0] === "string" && + (call[0] as string).startsWith("[rate-limit] upstash unavailable"), + ); + expect(matching).toHaveLength(2); + }); + + it("resets the log-gate on a successful check so the next failure logs immediately (Validates: Requirements R4.5)", async () => { + const clock = makeClock(); + const logger = vi.fn(); + const decision = okDecision("generation", clock.now() + 5_000); + const { backend } = makeScriptedBackend(["throw", decision, "throw"]); + + const wrapped = wrapFailOpen(backend, { now: clock.now, logger }); + + await wrapped.check({ bucket: "generation", clientId: "alice" }); // failure → log #1 + clock.advance(5_000); + const recovered = await wrapped.check({ bucket: "generation", clientId: "alice" }); // success → reset + expect(recovered).toEqual(decision); + + clock.advance(1_000); // well under 60 s since log #1 + await wrapped.check({ bucket: "generation", clientId: "alice" }); // failure → log #2 (not suppressed) + + const matching = logger.mock.calls.filter( + (call) => + typeof call[0] === "string" && + (call[0] as string).startsWith("[rate-limit] upstash unavailable"), + ); + expect(matching).toHaveLength(2); + }); + + it("treats a synchronous throw from the inner check() the same as a rejected promise (Validates: Requirements R4.5)", async () => { + const clock = makeClock(); + const logger = vi.fn(); + const { backend } = makeScriptedBackend(["sync-throw"]); + + const wrapped = wrapFailOpen(backend, { now: clock.now, logger }); + const out = await wrapped.check({ bucket: "read", clientId: "carol" }); + + expect(out.allowed).toBe(true); + expect(out.limit).toBe(BUCKETS.read.limit); + expect(out.remaining).toBe(BUCKETS.read.limit); + expect(out.resetAtEpochMs).toBe(clock.now() + BUCKETS.read.windowMs); + + const matching = logger.mock.calls.filter( + (call) => + typeof call[0] === "string" && + (call[0] as string).startsWith("[rate-limit] upstash unavailable"), + ); + expect(matching).toHaveLength(1); + }); + + it("preserves the inner backend's name (memory or upstash)", () => { + const logger = vi.fn(); + const memoryInner: RateLimitBackend = { + name: "memory", + check: async () => okDecision("read", 0), + }; + const upstreamInner: RateLimitBackend = { + name: "upstash", + check: async () => okDecision("read", 0), + }; + + expect(wrapFailOpen(memoryInner, { logger }).name).toBe("memory"); + expect(wrapFailOpen(upstreamInner, { logger }).name).toBe("upstash"); + }); +}); diff --git a/apps/api/src/rate-limit/fail-open.ts b/apps/api/src/rate-limit/fail-open.ts new file mode 100644 index 0000000..d94d7ea --- /dev/null +++ b/apps/api/src/rate-limit/fail-open.ts @@ -0,0 +1,137 @@ +/** + * Fail-open wrapper around any `RateLimitBackend`. + * + * Purpose + * ------- + * R4.5 requires that rate-limit backend failures (Upstash unreachable, + * timeout, 5xx, or any other rejection from `check()`) MUST NOT turn into + * HTTP 429. The request has to be allowed through, and the failure must be + * logged at most once per 60 s window so a sustained outage does not drown + * the stdout of the API service. + * + * This module takes a backend and returns a new backend with the same + * `name` but a `check()` that: + * + * 1. Awaits the inner `check()` inside a try/catch. This catches both + * rejected promises and synchronous throws from the inner function. + * 2. On success: passes the decision through unchanged and resets the + * log-gate so that the next failure logs again immediately. This is + * what design § 9 step 2 calls "restores normal accounting on the + * next successful check". + * 3. On failure: emits `"[rate-limit] upstash unavailable"` (optionally + * followed by `": "`) through the injected logger and + * returns a synthetic allow decision sized from `BUCKETS[bucket]`. + * The synthetic `remaining` is set to the bucket limit because we + * have no way to know the real remaining quota without the backend. + * This is explicitly what design § 3 ("fail-open.ts") specifies. + * + * Log-gate semantics + * ------------------ + * A minimal clock-gated counter tracks the epoch-ms of the last emitted + * log line. Any failure whose timestamp is <= lastLoggedAt + 60_000 is + * swallowed silently. A successful call clears `lastLoggedAt` (sets it to + * `null`) so that the *next* failure, regardless of how soon it happens, + * produces a fresh log line — the limiter has recovered and we want to + * know the moment it breaks again. + * + * No timers, no setInterval — the gate is evaluated lazily on every + * `check()`, matching the lazy-rollover style used by the memory backend. + * + * Tests inject both `now` and `logger` so assertions never touch + * `console.warn` globally. + */ + +import { BUCKETS } from "./buckets.js"; +import type { + RateLimitBackend, + RateLimitCheckArgs, + RateLimitDecision, +} from "./types.js"; + +const LOG_WINDOW_MS = 60_000; +const LOG_MESSAGE = "[rate-limit] upstash unavailable"; + +export interface FailOpenOptions { + /** + * Clock injection for deterministic tests. Defaults to `Date.now`. + */ + now?: () => number; + /** + * Logger injection for deterministic tests. Defaults to `console.warn`. + * The wrapper calls `logger(message, error)` on the first failure inside + * each 60 s window; the `error` argument lets callers preserve the + * original stack if they want to. + */ + logger?: (message: string, error?: unknown) => void; +} + +function defaultLogger(message: string, error?: unknown): void { + // Keep the production default quiet unless there is actually something + // to say. We forward the error as a second argument so structured log + // collectors (Railway, pino) can attach it. + // eslint-disable-next-line no-console + console.warn(message, error); +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && typeof error.message === "string" && error.message.length > 0) { + return error.message; + } + try { + return String(error); + } catch { + return "unknown error"; + } +} + +/** + * Wrap any `RateLimitBackend` with fail-open behavior. + * + * The returned backend keeps the inner backend's `name` — we are + * decorating, not introducing a new backend type. + */ +export function wrapFailOpen( + backend: RateLimitBackend, + options: FailOpenOptions = {}, +): RateLimitBackend { + const now = options.now ?? Date.now; + const logger = options.logger ?? defaultLogger; + + // `null` means "the gate is open — the next failure logs". A number + // means "we logged at this epoch-ms; suppress further failures until + // `lastLoggedAt + LOG_WINDOW_MS`". + let lastLoggedAt: number | null = null; + + return { + name: backend.name, + async check(args: RateLimitCheckArgs): Promise { + let decision: RateLimitDecision; + try { + // Awaiting inside the try/catch collapses both rejected promises + // and synchronous throws from the inner `check()` into a single + // failure path. + decision = await backend.check(args); + } catch (error) { + const nowMs = now(); + if (lastLoggedAt === null || nowMs - lastLoggedAt >= LOG_WINDOW_MS) { + logger(`${LOG_MESSAGE}: ${errorMessage(error)}`, error); + lastLoggedAt = nowMs; + } + + const config = BUCKETS[args.bucket]; + return { + allowed: true, + remaining: config.limit, + limit: config.limit, + resetAtEpochMs: nowMs + config.windowMs, + }; + } + + // Successful check — the backend is healthy, so reset the log-gate + // so the next failure produces a fresh warning instead of silently + // waiting out the remainder of the previous 60 s window. + lastLoggedAt = null; + return decision; + }, + }; +} diff --git a/apps/api/src/rate-limit/index.ts b/apps/api/src/rate-limit/index.ts new file mode 100644 index 0000000..70e3cae --- /dev/null +++ b/apps/api/src/rate-limit/index.ts @@ -0,0 +1,173 @@ +/** + * Public barrel for the rate-limit module. + * + * Exposes: + * - `createRateLimitMiddleware(bucket, limitOverride?)` — Hono middleware + * factory that the API mounts per route family (see `apps/api/src/app.ts`). + * - `rateLimitHealth()` — best-effort health probe used by the deploy + * smoke script (D4) and future `/health` extensions. + * - `__resetBackendForTests(backend?)` — test-only hook that lets the + * contract tests swap the process-wide backend instance per test so + * accounting is isolated. The leading `__` marks it as not-part-of-the + * -public-API; production code never imports this. + * + * Backend selection (design § 3, § 9): + * - `process.env.RATE_LIMIT_BACKEND === "upstash"` → Upstash + fail-open. + * - Any other value (including unset) → memory + fail-open. + * - If `upstash` is selected but `createUpstashBackend()` returns `null` + * (missing `UPSTASH_REDIS_REST_URL` / `_TOKEN`), the module falls back + * to the memory backend and logs a single warning with the exact + * string `[rate-limit] upstash env missing, falling back to memory` + * so log collectors can grep for it. + * + * One process-wide backend instance is held so the three `app.use(...)` + * calls (generation + read + `/api/v1/*`) share accounting across routes. + */ + +import type { MiddlewareHandler } from "hono/types"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +import { BUCKETS, type BucketName } from "./buckets.js"; +import { resolveClientId } from "./client-id.js"; +import { createMemoryBackend } from "./memory.js"; +import { createUpstashBackend } from "./upstash.js"; +import { wrapFailOpen } from "./fail-open.js"; +import type { RateLimitBackend } from "./types.js"; + +type Bindings = { + ADMIN_API_KEY?: string; + NODE_ENV?: string; +}; + +type Variables = { + requestId: string; +}; + +/** + * Process-wide backend instance. `null` means "not yet initialized" — the + * next call to `getBackend()` will construct one from `process.env`. Tests + * set this directly via `__resetBackendForTests` to inject spies / fakes. + */ +let activeBackend: RateLimitBackend | null = null; + +function selectBackend(): RateLimitBackend { + const flag = (process.env.RATE_LIMIT_BACKEND ?? "memory").trim().toLowerCase(); + + if (flag === "upstash") { + const upstash = createUpstashBackend(); + if (upstash) { + return wrapFailOpen(upstash); + } + console.warn("[rate-limit] upstash env missing, falling back to memory"); + return wrapFailOpen(createMemoryBackend()); + } + + return wrapFailOpen(createMemoryBackend()); +} + +function getBackend(): RateLimitBackend { + if (activeBackend === null) { + activeBackend = selectBackend(); + } + return activeBackend; +} + +/** + * Test-only: swap the process-wide backend instance. + * + * Pass a specific backend to inject a spy or fake (contract tests in + * `apps/api/src/app.test.ts` do this). Pass `null` / no argument to clear + * the instance so the next request lazily rebuilds from `process.env`. + * + * The injected backend is used as-is — no fail-open wrapping — so tests + * can observe the raw backend behavior. + */ +export function __resetBackendForTests(backend: RateLimitBackend | null = null): void { + activeBackend = backend; +} + +/** + * Build a Hono middleware that consults the process-wide rate-limit backend. + * + * The `limitOverride` argument is accepted for API compatibility with the + * previous inline factory but is deliberately ignored: `BUCKETS` is the + * single source of truth for per-bucket limits. Any call site passing an + * override that disagrees with `BUCKETS[bucket].limit` gets a one-time + * warning so operators notice the mismatch. + */ +export function createRateLimitMiddleware( + bucket: BucketName, + limitOverride?: number, +): MiddlewareHandler<{ Bindings: Bindings; Variables: Variables }> { + if ( + typeof limitOverride === "number" && + limitOverride !== BUCKETS[bucket].limit + ) { + console.warn( + `[rate-limit] limitOverride=${limitOverride} for bucket="${bucket}" ignored; using configured limit=${BUCKETS[bucket].limit}`, + ); + } + + return async (c, next) => { + const clientId = resolveClientId(c.req.raw.headers); + const backend = getBackend(); + + const decision = await backend.check({ bucket, clientId }); + + c.header("X-RateLimit-Limit", String(decision.limit)); + c.header("X-RateLimit-Remaining", String(decision.remaining)); + c.header( + "X-RateLimit-Reset", + String(Math.ceil(decision.resetAtEpochMs / 1000)), + ); + + if (!decision.allowed) { + const retryAfter = Math.max( + 1, + Math.ceil((decision.resetAtEpochMs - Date.now()) / 1000), + ); + c.header("Retry-After", String(retryAfter)); + return c.json( + { + error: "Rate limit exceeded", + requestId: c.get("requestId"), + }, + 429 as ContentfulStatusCode, + ); + } + + await next(); + }; +} + +/** + * Best-effort health probe for the active rate-limit backend. + * + * Uses a dedicated `clientId` (`"__health__"`) so real client quota is + * never consumed by a health check. Errors are captured, never thrown — + * the probe itself must never bring down the `/health` endpoint. + */ +export async function rateLimitHealth(): Promise<{ + backend: "memory" | "upstash"; + ok: boolean; + error?: string; +}> { + const backend = getBackend(); + try { + await backend.check({ bucket: "read", clientId: "__health__" }); + return { backend: backend.name, ok: true }; + } catch (error) { + return { + backend: backend.name, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export type { BucketName } from "./buckets.js"; +export type { + RateLimitBackend, + RateLimitDecision, + RateLimitCheckArgs, +} from "./types.js"; diff --git a/apps/api/src/rate-limit/memory.test.ts b/apps/api/src/rate-limit/memory.test.ts new file mode 100644 index 0000000..3635349 --- /dev/null +++ b/apps/api/src/rate-limit/memory.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { BUCKETS } from "./buckets.js"; +import { createMemoryBackend } from "./memory.js"; + +/** + * Tiny controllable clock — lets each test advance time deterministically. + */ +function makeClock(start = 1_700_000_000_000) { + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + set: (ms: number) => { + current = ms; + }, + }; +} + +describe("createMemoryBackend", () => { + it("increments the count within the window and decrements remaining", async () => { + const clock = makeClock(); + const backend = createMemoryBackend({ now: clock.now }); + + const first = await backend.check({ bucket: "generation", clientId: "alice" }); + expect(first.allowed).toBe(true); + expect(first.limit).toBe(BUCKETS.generation.limit); + expect(first.remaining).toBe(BUCKETS.generation.limit - 1); + expect(first.resetAtEpochMs).toBe(clock.now() + BUCKETS.generation.windowMs); + + const firstResetAt = first.resetAtEpochMs; + + clock.advance(1_000); + const second = await backend.check({ bucket: "generation", clientId: "alice" }); + expect(second.allowed).toBe(true); + expect(second.remaining).toBe(BUCKETS.generation.limit - 2); + // The reset time must not drift while we are still inside the window. + expect(second.resetAtEpochMs).toBe(firstResetAt); + }); + + it("rolls the window lazily at exactly resetAtEpochMs (R4.2 preserved)", async () => { + const clock = makeClock(); + const backend = createMemoryBackend({ now: clock.now }); + + const first = await backend.check({ bucket: "generation", clientId: "bob" }); + const firstResetAt = first.resetAtEpochMs; + + // Jump to exactly the reset boundary — the current window must have + // ended (now >= resetAtEpochMs) and the counter must restart at 1. + clock.set(firstResetAt); + const rolled = await backend.check({ bucket: "generation", clientId: "bob" }); + + expect(rolled.allowed).toBe(true); + expect(rolled.remaining).toBe(BUCKETS.generation.limit - 1); + expect(rolled.resetAtEpochMs).toBe(firstResetAt + BUCKETS.generation.windowMs); + }); + + it("rolls the window lazily after resetAtEpochMs has passed", async () => { + const clock = makeClock(); + const backend = createMemoryBackend({ now: clock.now }); + + const first = await backend.check({ bucket: "read", clientId: "carol" }); + const firstResetAt = first.resetAtEpochMs; + + clock.set(firstResetAt + 5_000); + const rolled = await backend.check({ bucket: "read", clientId: "carol" }); + + expect(rolled.allowed).toBe(true); + expect(rolled.remaining).toBe(BUCKETS.read.limit - 1); + expect(rolled.resetAtEpochMs).toBe(firstResetAt + 5_000 + BUCKETS.read.windowMs); + }); + + it("keeps bucket keyspaces isolated: generation does not consume from read", async () => { + const clock = makeClock(); + const backend = createMemoryBackend({ now: clock.now }); + + // Exhaust the generation bucket for one client. + for (let index = 0; index < BUCKETS.generation.limit; index += 1) { + await backend.check({ bucket: "generation", clientId: "dave" }); + } + const genBlocked = await backend.check({ bucket: "generation", clientId: "dave" }); + expect(genBlocked.allowed).toBe(false); + expect(genBlocked.remaining).toBe(0); + + // The same clientId on the read bucket must start fresh. + const readFirst = await backend.check({ bucket: "read", clientId: "dave" }); + expect(readFirst.allowed).toBe(true); + expect(readFirst.remaining).toBe(BUCKETS.read.limit - 1); + expect(readFirst.limit).toBe(BUCKETS.read.limit); + }); + + it("keeps per-client counts independent within the same bucket", async () => { + const clock = makeClock(); + const backend = createMemoryBackend({ now: clock.now }); + + const a1 = await backend.check({ bucket: "generation", clientId: "alice" }); + const a2 = await backend.check({ bucket: "generation", clientId: "alice" }); + const b1 = await backend.check({ bucket: "generation", clientId: "bob" }); + + expect(a1.remaining).toBe(BUCKETS.generation.limit - 1); + expect(a2.remaining).toBe(BUCKETS.generation.limit - 2); + // Bob is on his first request regardless of how many Alice spent. + expect(b1.remaining).toBe(BUCKETS.generation.limit - 1); + expect(b1.allowed).toBe(true); + }); + + it("allows exactly `limit` requests and blocks the (limit + 1)-th in the same window", async () => { + const clock = makeClock(); + const backend = createMemoryBackend({ now: clock.now }); + const limit = BUCKETS.generation.limit; + + // First `limit - 1` calls — all allowed, remaining strictly positive. + for (let index = 1; index <= limit - 1; index += 1) { + const decision = await backend.check({ bucket: "generation", clientId: "eve" }); + expect(decision.allowed).toBe(true); + expect(decision.remaining).toBe(limit - index); + } + + // The `limit`-th call — still allowed (count === limit) with remaining 0. + const atLimit = await backend.check({ bucket: "generation", clientId: "eve" }); + expect(atLimit.allowed).toBe(true); + expect(atLimit.remaining).toBe(0); + + // The (limit + 1)-th call — blocked. + const over = await backend.check({ bucket: "generation", clientId: "eve" }); + expect(over.allowed).toBe(false); + expect(over.remaining).toBe(0); + expect(over.limit).toBe(limit); + }); + + it("defaults to Date.now when no clock is injected", async () => { + const backend = createMemoryBackend(); + const before = Date.now(); + const decision = await backend.check({ bucket: "read", clientId: "frank" }); + const after = Date.now(); + + expect(decision.allowed).toBe(true); + expect(decision.resetAtEpochMs).toBeGreaterThanOrEqual(before + BUCKETS.read.windowMs); + expect(decision.resetAtEpochMs).toBeLessThanOrEqual(after + BUCKETS.read.windowMs); + }); + + it("exposes the backend name as 'memory' for logging and health checks", () => { + const backend = createMemoryBackend(); + expect(backend.name).toBe("memory"); + }); +}); diff --git a/apps/api/src/rate-limit/memory.ts b/apps/api/src/rate-limit/memory.ts new file mode 100644 index 0000000..d682cf5 --- /dev/null +++ b/apps/api/src/rate-limit/memory.ts @@ -0,0 +1,78 @@ +/** + * In-process memory backend for the rate limiter. + * + * This is a faithful port of the inline `rateLimit(bucket, limit)` factory in + * `apps/api/src/app.ts` — same fixed-window accounting, same lazy rollover, + * same "first request in a fresh window counts as 1" semantics — lifted + * behind the `RateLimitBackend` interface so: + * + * - existing contract tests keep passing (regression net for R4.2 / R4.3), + * - tests have a deterministic backend when `RATE_LIMIT_BACKEND=memory`, + * - the Upstash backend (A4) drops into the same factory without any + * changes to `app.ts`. + * + * Design references: + * - design.md § 3 ("Module boundaries — rate limiter") + * - design.md § 9 step 1 (lazy rollover; no `setInterval`) + */ + +import { BUCKETS } from "./buckets.js"; +import type { + RateLimitBackend, + RateLimitCheckArgs, + RateLimitDecision, +} from "./types.js"; + +interface MemoryEntry { + count: number; + resetAtEpochMs: number; +} + +export interface MemoryBackendOptions { + /** + * Clock injection for deterministic tests. Defaults to `Date.now` in + * production. The factory only ever calls this through `opts.now()` so a + * test can freeze or advance time without touching globals. + */ + now?: () => number; +} + +/** + * Create a process-local rate-limit backend. + * + * Keys are `${bucket}:${clientId}` so the same client id counted in the + * `generation` bucket cannot consume quota from the `read` bucket + * (cross-bucket isolation, part of R4.4). + */ +export function createMemoryBackend( + options: MemoryBackendOptions = {}, +): RateLimitBackend { + const now = options.now ?? Date.now; + const entries = new Map(); + + return { + name: "memory", + async check({ bucket, clientId }: RateLimitCheckArgs): Promise { + const config = BUCKETS[bucket]; + const key = `${bucket}:${clientId}`; + const nowMs = now(); + + let entry = entries.get(key); + if (!entry || nowMs >= entry.resetAtEpochMs) { + // Lazy rollover — no setInterval, no background sweep. The previous + // window is replaced in place by the first request of the new one. + entry = { count: 1, resetAtEpochMs: nowMs + config.windowMs }; + entries.set(key, entry); + } else { + entry.count += 1; + } + + return { + allowed: entry.count <= config.limit, + remaining: Math.max(0, config.limit - entry.count), + limit: config.limit, + resetAtEpochMs: entry.resetAtEpochMs, + }; + }, + }; +} diff --git a/apps/api/src/rate-limit/rate-limit.pbt.test.ts b/apps/api/src/rate-limit/rate-limit.pbt.test.ts new file mode 100644 index 0000000..09b62c9 --- /dev/null +++ b/apps/api/src/rate-limit/rate-limit.pbt.test.ts @@ -0,0 +1,217 @@ +/** + * Rate-limit property-based tests. + * + * Scope + * ----- + * This file covers **Property 1** from + * [`design.md` § 8 "Testing strategy — Property-based tests"] + * (../../../../.kiro/specs/phase-8-deployment/design.md): + * + * > **Property 1 — Upstash failures never produce a 429 (fail-open).** + * > For any sequence of generator-produced requests where a random subset + * > of them trigger an injected `RateLimitBackend.check()` failure, the + * > corresponding responses MUST have status in `{200, 401, 404}` — never + * > `429`. All other request indices are unconstrained (they may be 200 + * > or 429 depending on quota). + * + * This is the rate-limit PBT file. The sibling Sentry PBT (Property 2) + * lands in B2, and the app-level PBTs (Properties 3–5) land in C2 + * alongside `app.pbt.test.ts`. + * + * Acceptance criterion + * -------------------- + * **Validates: Requirements R4.5** — rate-limit backend failures must + * never produce HTTP 429. A3 already covers the invariant with example + * unit tests; this file re-asserts the same invariant across the whole + * input space that fast-check can reach. + * + * Harness shape + * ------------- + * Per the task brief we drive the property through a minimal in-test + * harness rather than booting the real Hono app. The harness composition + * mirrors what `apps/api/src/app.ts` will do after A6: + * + * scriptedBackend — delegates to a real `createMemoryBackend` instance + * when the generator says "do not fail", and rejects + * with an `Error` when the generator says "fail". + * The shared memory instance keeps quota accounting + * realistic so non-failed calls behave like a real + * request would. + * wrapFailOpen — the A3 wrapper under test. Must swallow any inner + * `check()` error and return a synthetic allow. + * handler — the two-line equivalent of the Hono middleware: + * return 429 iff `decision.allowed === false`, else + * return 200. Nothing else can produce a 429 in + * this harness. + * + * Because the harness has no auth middleware and no router, statuses + * `401` and `404` are unreachable here — the design-level allowed set + * `{200, 401, 404}` collapses to `{200}`. The stricter assertion we + * actually need for Property 1 is the negative one: **whenever the + * scripted backend failed for request `i`, the observed status MUST NOT + * be 429**. That is exactly what design § 8 Property 1 requires. + * + * Determinism + * ----------- + * The harness uses a fixed-seeded deterministic clock (same shape as + * `memory.test.ts` and `fail-open.test.ts`). Each property run advances + * the clock by 1 ms per request so the memory backend's lazy rollover + * behaves predictably across sequence lengths up to the window size. + * fast-check is invoked with `numRuns: 100` and a fixed seed so failures + * produce a reproducible counterexample. + */ + +import { describe, expect, it } from "vitest"; +import fc from "fast-check"; +import { BUCKET_NAMES, type BucketName } from "./buckets.js"; +import { wrapFailOpen } from "./fail-open.js"; +import { createMemoryBackend } from "./memory.js"; +import type { RateLimitBackend } from "./types.js"; + +/** + * Deterministic clock matching the one in `memory.test.ts` / + * `fail-open.test.ts`. Starts at a fixed epoch-ms so fast-check + * counterexamples are reproducible regardless of wall-clock drift. + */ +function makeClock(start = 1_700_000_000_000): { + now: () => number; + advance: (ms: number) => void; +} { + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; +} + +/** + * Drive one generator-produced sequence through the harness and collect + * the observed statuses per request index. Returns the status array so + * the property assertion can inspect the correspondence between + * `failures[i]` and `statuses[i]`. + */ +async function runSequence(input: { + failures: readonly boolean[]; + bucket: BucketName; + clientIds: readonly string[]; +}): Promise { + const clock = makeClock(); + + // Build a fresh memory backend per run so quota accounting is + // isolated between property iterations. The scripted backend below + // delegates into this instance for non-failed calls. + const memory = createMemoryBackend({ now: clock.now }); + let callIndex = 0; + const scripted: RateLimitBackend = { + name: "upstash", + async check(args) { + const i = callIndex++; + if (input.failures[i] === true) { + throw new Error(`scripted upstash failure at call #${i}`); + } + return memory.check(args); + }, + }; + + // Silent logger so the `[rate-limit] upstash unavailable` line does + // not spam test output during property shrinking. + const wrapped = wrapFailOpen(scripted, { + now: clock.now, + logger: () => { + /* intentionally silent in tests */ + }, + }); + + const statuses: number[] = []; + for (let i = 0; i < input.failures.length; i += 1) { + const decision = await wrapped.check({ + bucket: input.bucket, + clientId: input.clientIds[i] ?? "anon", + }); + // Middleware-equivalent: a disallowed decision is the only path to + // 429 in the real Hono middleware. A successful decision yields a + // handler response, modeled here as 200 (the auth / not-found 401 / + // 404 exits are not reachable in this minimal harness). + statuses.push(decision.allowed ? 200 : 429); + + // Advance the clock by 1 ms per request so that, for sequences + // close to the bucket window size, the memory backend's lazy + // rollover behaves the same way it will in production without + // forcing an artificial rollover mid-run. + clock.advance(1); + } + + return statuses; +} + +describe("rate-limit fail-open property (design § 8 Property 1)", () => { + // Keep sequences under both bucket limits so that non-failing calls + // alone cannot legitimately produce a 429 within a run — any 429 we + // ever see in this harness must come from a truly over-quota + // non-failing call, which is fine for the property because the + // invariant only constrains indices where `failures[i]` is true. + // + // `numRuns: 100` keeps the whole file well under the ~10 s budget + // called out in the task brief. The fixed seed makes failures + // reproducible. + it("never returns 429 for a request whose backend call failed (Validates: Requirements R4.5)", async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + // One boolean per request, minLength 1 so the property + // always exercises at least one `check()`. + failures: fc.array(fc.boolean(), { minLength: 1, maxLength: 100 }), + bucket: fc.constantFrom(...BUCKET_NAMES), + // Small client-id pool keeps quota contention realistic — + // some runs will concentrate all traffic on one client, + // others will spread it across two or three. `fc.webSegment` + // (per design § 8 Property 1 generator) produces URL-safe + // strings that match what real `resolveClientId` outputs. + clientPool: fc.array(fc.webSegment(), { minLength: 1, maxLength: 3 }), + // Per-request client index into `clientPool` — generated at + // the same length as `failures` inside the predicate so the + // two arrays always align. + clientIndices: fc.array(fc.nat(2), { minLength: 1, maxLength: 100 }), + }), + async ({ failures, bucket, clientPool, clientIndices }) => { + // Align lengths: if the generator produced mismatched + // lengths, truncate to the shorter so every failure bit has + // a matching client id. + const n = Math.min(failures.length, clientIndices.length); + const trimmedFailures = failures.slice(0, n); + const clientIds = clientIndices + .slice(0, n) + .map((idx) => clientPool[idx % clientPool.length] ?? "anon"); + + const statuses = await runSequence({ + failures: trimmedFailures, + bucket, + clientIds, + }); + + // The property: for every index where the backend failed, + // the observed status MUST NOT be 429. (Design § 8 states + // the allowed set as `{200, 401, 404}`; in this minimal + // harness 401 / 404 are unreachable, so the invariant + // collapses to "status === 200" for failed indices.) + for (let i = 0; i < trimmedFailures.length; i += 1) { + if (trimmedFailures[i] === true) { + expect(statuses[i]).not.toBe(429); + expect(statuses[i]).toBe(200); + } + } + }, + ), + { + numRuns: 100, + // Fixed seed so any counterexample is reproducible across CI + // runs and local replays. Drop / change this seed to rotate + // the input space during exploratory runs. + seed: 424242, + verbose: true, + }, + ); + }); +}); diff --git a/apps/api/src/rate-limit/types.ts b/apps/api/src/rate-limit/types.ts new file mode 100644 index 0000000..e09b5eb --- /dev/null +++ b/apps/api/src/rate-limit/types.ts @@ -0,0 +1,50 @@ +/** + * Shared types for the rate-limit module. + * + * Lives in its own file so the memory backend (A2), the fail-open wrapper + * (A3), and the Upstash backend (A4) can all import `RateLimitBackend` and + * `RateLimitDecision` without creating an import cycle through the concrete + * backend implementations. + */ + +import type { BucketName } from "./buckets.js"; + +/** + * Result of a single `check()` call on any `RateLimitBackend`. + * + * - `allowed` is `true` when the request is within quota (count ≤ limit). + * - `remaining` is the non-negative remaining quota for the current window. + * - `limit` echoes the bucket's configured limit so callers do not need to + * look it up again when building `X-RateLimit-*` headers. + * - `resetAtEpochMs` is the absolute epoch-ms at which the current window + * rolls over. + */ +export interface RateLimitDecision { + readonly allowed: boolean; + readonly remaining: number; + readonly limit: number; + readonly resetAtEpochMs: number; +} + +/** + * Arguments to `RateLimitBackend.check()`. Grouped as an object so call + * sites read as `check({ bucket, clientId })` rather than two positional + * strings that are easy to swap. + */ +export interface RateLimitCheckArgs { + readonly bucket: BucketName; + readonly clientId: string; +} + +/** + * Contract every rate-limit backend implements. + * + * `name` tags the backend for logging and health checks (design § 3). + * `check()` is the single hot-path entry point and MUST be idempotent-safe + * with respect to errors — the fail-open wrapper (A3) assumes that a + * rejected promise means the request should be allowed through. + */ +export interface RateLimitBackend { + readonly name: "memory" | "upstash"; + check(args: RateLimitCheckArgs): Promise; +} diff --git a/apps/api/src/rate-limit/upstash.test.ts b/apps/api/src/rate-limit/upstash.test.ts new file mode 100644 index 0000000..915ce3c --- /dev/null +++ b/apps/api/src/rate-limit/upstash.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { BUCKETS } from "./buckets.js"; +import { + createUpstashBackend, + type RatelimitInstance, + type RatelimitResponse, +} from "./upstash.js"; + +/** + * Unit tests for the Upstash rate-limit backend. + * + * Everything here uses constructor injection (`redisCtor` / + * `ratelimitCtor`) rather than `vi.mock("@upstash/*")` — the module is + * designed so tests never need to touch the real library. This keeps the + * A6 factory decoupled from the Upstash import graph and leaves the door + * open for the staging soak (G6) to exercise the real client unmocked. + * + * Validates: Requirements R4.1, R4.6. + */ + +const REAL_URL = process.env.UPSTASH_REDIS_REST_URL; +const REAL_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN; + +beforeEach(() => { + // Guarantee the test-setup defaults do not leak real credentials into + // the factory's env-fallback path during this file's runs. + delete process.env.UPSTASH_REDIS_REST_URL; + delete process.env.UPSTASH_REDIS_REST_TOKEN; +}); + +afterEach(() => { + if (REAL_URL !== undefined) process.env.UPSTASH_REDIS_REST_URL = REAL_URL; + if (REAL_TOKEN !== undefined) process.env.UPSTASH_REDIS_REST_TOKEN = REAL_TOKEN; +}); + +function makeFakeLimiter(response: RatelimitResponse): { + instance: RatelimitInstance; + calls: string[]; +} { + const calls: string[] = []; + return { + calls, + instance: { + async limit(identifier: string): Promise { + calls.push(identifier); + return response; + }, + }, + }; +} + +describe("createUpstashBackend", () => { + it("returns null when UPSTASH_REDIS_REST_URL is missing", () => { + const backend = createUpstashBackend({ token: "t" }); + expect(backend).toBeNull(); + }); + + it("returns null when UPSTASH_REDIS_REST_TOKEN is missing", () => { + const backend = createUpstashBackend({ url: "https://example.upstash.io" }); + expect(backend).toBeNull(); + }); + + it("returns null when both URL and token are missing (simulates unset env)", () => { + const backend = createUpstashBackend(); + expect(backend).toBeNull(); + }); + + it("returns null when env vars are set to empty strings", () => { + process.env.UPSTASH_REDIS_REST_URL = ""; + process.env.UPSTASH_REDIS_REST_TOKEN = ""; + const backend = createUpstashBackend(); + expect(backend).toBeNull(); + }); + + it("maps a successful response to a RateLimitDecision whose resetAtEpochMs matches the window", async () => { + const reset = 1_700_000_060_000; + const { instance, calls } = makeFakeLimiter({ + success: true, + remaining: 29, + reset, + limit: BUCKETS.generation.limit, + }); + + const backend = createUpstashBackend({ + url: "https://example.upstash.io", + token: "secret", + redisCtor: () => ({}), + ratelimitCtor: () => instance, + }); + + expect(backend).not.toBeNull(); + const decision = await backend!.check({ bucket: "generation", clientId: "alice" }); + + expect(decision.allowed).toBe(true); + expect(decision.remaining).toBe(29); + // The decision MUST echo our configured limit, not whatever the + // library reports — we are the source of truth for bucket sizes. + expect(decision.limit).toBe(BUCKETS.generation.limit); + expect(decision.resetAtEpochMs).toBe(reset); + + // And the identifier is composed as `${bucket}:${clientId}` per R4.4. + expect(calls).toEqual(["generation:alice"]); + }); + + it("maps a blocked response to allowed: false, remaining: 0", async () => { + const { instance } = makeFakeLimiter({ + success: false, + remaining: -1, // real library can return negative when over quota + reset: 1_700_000_120_000, + limit: BUCKETS.generation.limit, + }); + + const backend = createUpstashBackend({ + url: "https://example.upstash.io", + token: "secret", + redisCtor: () => ({}), + ratelimitCtor: () => instance, + }); + + const decision = await backend!.check({ bucket: "generation", clientId: "bob" }); + expect(decision.allowed).toBe(false); + // remaining must be clamped to >= 0 even if the library returns a + // negative value. + expect(decision.remaining).toBe(0); + expect(decision.limit).toBe(BUCKETS.generation.limit); + expect(decision.resetAtEpochMs).toBe(1_700_000_120_000); + }); + + it("exposes the backend name as 'upstash' for logging and health checks", () => { + const { instance } = makeFakeLimiter({ + success: true, + remaining: 5, + reset: 1_700_000_000_000, + limit: BUCKETS.read.limit, + }); + + const backend = createUpstashBackend({ + url: "https://example.upstash.io", + token: "secret", + redisCtor: () => ({}), + ratelimitCtor: () => instance, + }); + + expect(backend).not.toBeNull(); + expect(backend!.name).toBe("upstash"); + }); + + it("falls back to process.env credentials when not passed as options", () => { + process.env.UPSTASH_REDIS_REST_URL = "https://env.upstash.io"; + process.env.UPSTASH_REDIS_REST_TOKEN = "env-token"; + + const captured: { url?: string; token?: string } = {}; + const backend = createUpstashBackend({ + redisCtor: (config) => { + captured.url = config.url; + captured.token = config.token; + return {}; + }, + ratelimitCtor: () => ({ + limit: async () => ({ + success: true, + remaining: 1, + reset: 1_700_000_000_000, + limit: BUCKETS.read.limit, + }), + }), + }); + + expect(backend).not.toBeNull(); + expect(captured).toEqual({ url: "https://env.upstash.io", token: "env-token" }); + }); + + it("uses a distinct ratelimit instance per bucket so keyspaces stay isolated", async () => { + const generationCalls: string[] = []; + const readCalls: string[] = []; + + const backend = createUpstashBackend({ + url: "https://example.upstash.io", + token: "secret", + redisCtor: () => ({}), + // Return a different fake limiter per constructor call so we can + // observe which bucket reached which instance. + ratelimitCtor: (() => { + let index = 0; + return () => { + const current = index; + index += 1; + return { + async limit(identifier: string): Promise { + (current === 0 ? generationCalls : readCalls).push(identifier); + return { + success: true, + remaining: 1, + reset: 1_700_000_000_000, + limit: 1, + }; + }, + }; + }; + })(), + }); + + await backend!.check({ bucket: "generation", clientId: "alice" }); + await backend!.check({ bucket: "read", clientId: "alice" }); + + expect(generationCalls).toEqual(["generation:alice"]); + expect(readCalls).toEqual(["read:alice"]); + }); +}); diff --git a/apps/api/src/rate-limit/upstash.ts b/apps/api/src/rate-limit/upstash.ts new file mode 100644 index 0000000..aad45d7 --- /dev/null +++ b/apps/api/src/rate-limit/upstash.ts @@ -0,0 +1,186 @@ +/** + * Upstash Redis backend for the rate limiter. + * + * Purpose + * ------- + * Implements the `RateLimitBackend` contract on top of `@upstash/ratelimit` + * (sliding-window counter) + `@upstash/redis` (REST client). This is the + * backend that ships to Railway in production and staging once + * `RATE_LIMIT_BACKEND=upstash` is set (design § 9 step 3-4). The A6 factory + * picks between this and `memory.ts` based on the env flag, so this module + * is deliberately decoupled from `app.ts` — nothing here mutates + * process-wide state or reaches for Hono. + * + * Construction contract + * --------------------- + * `createUpstashBackend()` returns `RateLimitBackend | null`. When the + * Upstash credentials (`UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN`) + * are missing it returns `null` — design § 9 step 2 is the spec: the + * factory silently falls back to the memory backend instead of throwing, + * so that flipping `RATE_LIMIT_BACKEND=upstash` without provisioning + * credentials never breaks boot. The factory (A6) detects the `null` and + * logs the fallback. + * + * Dependency injection + * -------------------- + * The options accept `redisCtor` and `ratelimitCtor` factory functions so + * unit tests can inject fakes without `vi.mock()` on `@upstash/*`. Defaults + * construct the real `Redis` and `Ratelimit` classes. No module-level + * `new Redis()` call exists — clients are only built inside + * `createUpstashBackend()` once the credentials are confirmed present. + * + * Key layout + * ---------- + * Keys are `${bucket}:${clientId}` with a short `"rl"` prefix applied by + * the `Ratelimit` instance itself. This matches the `{bucket}:{clientId}` + * shape required by R4.4 and keeps the generation and read buckets in + * disjoint keyspaces (the bucket name is part of the key and one + * `Ratelimit` instance is constructed per bucket). + * + * Response mapping + * ---------------- + * `Ratelimit.limit()` returns `{ success, remaining, reset, limit, ... }` + * where `reset` is already epoch-ms per the library. We map: + * - `allowed` ← `response.success` + * - `remaining` ← `max(0, response.remaining)` + * - `limit` ← `BUCKETS[bucket].limit` (echo our config; we do + * NOT trust the response here in case the server + * ever returns a different number for a given + * window — we are the source of truth for the + * bucket sizes) + * - `resetAtEpochMs` ← `response.reset` + */ + +import { Ratelimit as RatelimitImpl } from "@upstash/ratelimit"; +import { Redis as RedisImpl } from "@upstash/redis"; + +import { BUCKETS, BUCKET_NAMES, type BucketName } from "./buckets.js"; +import type { + RateLimitBackend, + RateLimitCheckArgs, + RateLimitDecision, +} from "./types.js"; + +/** + * Observable shape of `Ratelimit.limit()`'s response. Declared locally so + * the tests can produce matching objects without importing the full + * `@upstash/ratelimit` types. + */ +export interface RatelimitResponse { + readonly success: boolean; + readonly remaining: number; + /** Epoch milliseconds at which the current window resets. */ + readonly reset: number; + readonly limit: number; +} + +export interface RatelimitInstance { + limit(identifier: string): Promise; +} + +export type RedisCtor = (config: { url: string; token: string }) => unknown; + +export type RatelimitCtor = (config: { + redis: unknown; + limiter: unknown; + prefix?: string; +}) => RatelimitInstance; + +export interface UpstashBackendOptions { + /** Defaults to `process.env.UPSTASH_REDIS_REST_URL`. */ + url?: string; + /** Defaults to `process.env.UPSTASH_REDIS_REST_TOKEN`. */ + token?: string; + /** + * Injected Redis client factory. Defaults to `new Redis(config)` from + * `@upstash/redis`. Tests pass a stub that returns an opaque sentinel. + */ + redisCtor?: RedisCtor; + /** + * Injected Ratelimit instance factory. Defaults to `new Ratelimit(config)` + * from `@upstash/ratelimit`. Tests pass a stub that returns + * `{ limit: async () => }`. + */ + ratelimitCtor?: RatelimitCtor; +} + +const KEY_PREFIX = "rl"; + +function defaultRedisCtor(config: { url: string; token: string }): unknown { + return new RedisImpl(config); +} + +function defaultRatelimitCtor(config: { + redis: unknown; + limiter: unknown; + prefix?: string; +}): RatelimitInstance { + // The real `Ratelimit` constructor expects a `Redis` instance and a + // `limiter` produced by one of its static helpers (slidingWindow, + // fixedWindow, etc). The cast keeps the DI seam clean without pulling + // the library's internal types into our public surface. + return new RatelimitImpl( + config as unknown as ConstructorParameters[0], + ) as unknown as RatelimitInstance; +} + +/** + * Build an Upstash-backed rate-limit backend. + * + * Returns `null` when either `UPSTASH_REDIS_REST_URL` or + * `UPSTASH_REDIS_REST_TOKEN` is missing — the caller (the A6 factory) is + * responsible for falling back to the memory backend in that case. We + * deliberately do not throw: flipping `RATE_LIMIT_BACKEND=upstash` before + * provisioning credentials must not crash the API on boot. + */ +export function createUpstashBackend( + options: UpstashBackendOptions = {}, +): RateLimitBackend | null { + const url = options.url ?? process.env.UPSTASH_REDIS_REST_URL; + const token = options.token ?? process.env.UPSTASH_REDIS_REST_TOKEN; + + if (!url || !token) { + return null; + } + + const redisCtor = options.redisCtor ?? defaultRedisCtor; + const ratelimitCtor = options.ratelimitCtor ?? defaultRatelimitCtor; + + // One Redis client reused across every bucket — the REST transport is + // stateless and there is no connection pool to share, but re-using the + // instance avoids duplicate configuration validation in the library. + const redis = redisCtor({ url, token }); + + // One `Ratelimit` instance per bucket. The limiter encodes both the + // quota and the window, so the two buckets cannot share one instance. + // The sliding-window helper takes a duration string like `"60 s"` — + // we round up to the nearest whole second (windowMs is always a + // multiple of 1000 in BUCKETS, but Math.max(1, ...) keeps us honest + // against future sub-second configs). + const limiters = {} as Record; + for (const bucket of BUCKET_NAMES) { + const { limit, windowMs } = BUCKETS[bucket]; + const seconds = Math.max(1, Math.round(windowMs / 1000)); + const limiter = RatelimitImpl.slidingWindow(limit, `${seconds} s`); + limiters[bucket] = ratelimitCtor({ + redis, + limiter, + prefix: KEY_PREFIX, + }); + } + + return { + name: "upstash", + async check({ bucket, clientId }: RateLimitCheckArgs): Promise { + const limiter = limiters[bucket]; + const response = await limiter.limit(`${bucket}:${clientId}`); + const { limit } = BUCKETS[bucket]; + return { + allowed: response.success, + remaining: Math.max(0, response.remaining), + limit, + resetAtEpochMs: response.reset, + }; + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d88aa3f..532e6a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@better-auth/infra': specifier: ^0.2.7 - version: 0.2.7(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/sso@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)))(better-call@1.3.5(zod@4.4.3)))(better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)))(zod@4.4.3) + version: 0.2.7(4dlgsdvqqthuy6qz7v6sgg3pm4) devDependencies: '@eslint/js': specifier: ^9.17.0 @@ -72,12 +72,18 @@ importers: '@stackfast/shared': specifier: workspace:* version: link:../../packages/shared + '@upstash/ratelimit': + specifier: ^2.0.8 + version: 2.0.8(@upstash/redis@1.38.0) + '@upstash/redis': + specifier: ^1.38.0 + version: 1.38.0 better-auth: specifier: ^1.1.13 - version: 1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@22.19.18)) + version: 1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@22.19.18)) drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17) + version: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17) hono: specifier: ^4.6.1 version: 4.12.18 @@ -88,6 +94,9 @@ importers: '@types/node': specifier: ^22.10.2 version: 22.19.18 + fast-check: + specifier: ^4.8.0 + version: 4.8.0 tsx: specifier: ^4.19.2 version: 4.21.0 @@ -129,7 +138,7 @@ importers: version: 5.100.9(react@18.3.1) better-auth: specifier: ^1.1.13 - version: 1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)) + version: 1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)) class-variance-authority: specifier: ^0.7.0 version: 0.7.1 @@ -269,10 +278,10 @@ importers: dependencies: drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17) + version: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17) drizzle-zod: specifier: ^0.5.1 - version: 0.5.1(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(zod@3.25.76) + version: 0.5.1(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17))(zod@3.25.76) zod: specifier: ^3.23.0 version: 3.25.76 @@ -1491,6 +1500,18 @@ packages: resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@upstash/core-analytics@0.0.10': + resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==} + engines: {node: '>=16.0.0'} + + '@upstash/ratelimit@2.0.8': + resolution: {integrity: sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==} + peerDependencies: + '@upstash/redis': ^1.34.3 + + '@upstash/redis@1.38.0': + resolution: {integrity: sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==} + '@vercel/oidc@3.2.0': resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} @@ -2014,6 +2035,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-check@4.8.0: + resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2491,6 +2516,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2762,6 +2790,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3114,19 +3145,19 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 - '@better-auth/drizzle-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))': + '@better-auth/drizzle-adapter@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17))': dependencies: '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: - drizzle-orm: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17) + drizzle-orm: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17) - '@better-auth/infra@0.2.7(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/sso@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)))(better-call@1.3.5(zod@4.4.3)))(better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)))(zod@4.4.3)': + '@better-auth/infra@0.2.7(4dlgsdvqqthuy6qz7v6sgg3pm4)': dependencies: '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/sso': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)))(better-call@1.3.5(zod@4.4.3)) + '@better-auth/sso': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)))(better-call@1.3.5(zod@4.4.3)) '@better-fetch/fetch': 1.1.21 - better-auth: 1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)) + better-auth: 1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)) better-call: 1.3.5(zod@4.4.3) jose: 6.2.3 libphonenumber-js: 1.13.1 @@ -3154,12 +3185,12 @@ snapshots: '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/sso@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)))(better-call@1.3.5(zod@4.4.3))': + '@better-auth/sso@1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)))(better-call@1.3.5(zod@4.4.3))': dependencies: '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 - better-auth: 1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)) + better-auth: 1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)) better-call: 1.3.5(zod@4.4.3) fast-xml-parser: 5.7.3 jose: 6.2.3 @@ -3961,6 +3992,19 @@ snapshots: '@typescript-eslint/types': 8.59.2 eslint-visitor-keys: 5.0.1 + '@upstash/core-analytics@0.0.10': + dependencies: + '@upstash/redis': 1.38.0 + + '@upstash/ratelimit@2.0.8(@upstash/redis@1.38.0)': + dependencies: + '@upstash/core-analytics': 0.0.10 + '@upstash/redis': 1.38.0 + + '@upstash/redis@1.38.0': + dependencies: + uncrypto: 0.1.3 + '@vercel/oidc@3.2.0': {} '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.6.2))': @@ -4082,10 +4126,10 @@ snapshots: baseline-browser-mapping@2.10.29: {} - better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@22.19.18)): + better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@22.19.18)): dependencies: '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17)) + '@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17)) '@better-auth/kysely-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) '@better-auth/memory-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) '@better-auth/mongo-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) @@ -4095,14 +4139,14 @@ snapshots: '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.2.0 '@noble/hashes': 2.2.0 - better-call: 1.3.5(zod@4.4.3) + better-call: 1.3.5(zod@3.25.76) defu: 6.1.7 jose: 6.2.3 kysely: 0.28.17 nanostores: 1.3.0 zod: 4.4.3 optionalDependencies: - drizzle-orm: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17) + drizzle-orm: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) vitest: 2.1.9(@types/node@22.19.18) @@ -4110,10 +4154,10 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)): + better-auth@1.6.10(@opentelemetry/api@1.9.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@2.1.9(@types/node@25.6.2)): dependencies: '@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17)) + '@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17)) '@better-auth/kysely-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) '@better-auth/memory-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) '@better-auth/mongo-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) @@ -4130,7 +4174,7 @@ snapshots: nanostores: 1.3.0 zod: 4.4.3 optionalDependencies: - drizzle-orm: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17) + drizzle-orm: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) vitest: 2.1.9(@types/node@25.6.2) @@ -4138,6 +4182,15 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' + better-call@1.3.5(zod@3.25.76): + dependencies: + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.1.0 + optionalDependencies: + zod: 3.25.76 + better-call@1.3.5(zod@4.4.3): dependencies: '@better-auth/utils': 0.4.0 @@ -4283,16 +4336,17 @@ snapshots: dotenv@17.4.2: {} - drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17): + drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17): optionalDependencies: '@neondatabase/serverless': 0.10.4 '@opentelemetry/api': 1.9.0 '@types/pg': 8.11.6 + '@upstash/redis': 1.38.0 kysely: 0.28.17 - drizzle-zod@0.5.1(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17))(zod@3.25.76): + drizzle-zod@0.5.1(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17))(zod@3.25.76): dependencies: - drizzle-orm: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(kysely@0.28.17) + drizzle-orm: 0.45.2(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@upstash/redis@1.38.0)(kysely@0.28.17) zod: 3.25.76 electron-to-chromium@1.5.353: {} @@ -4442,6 +4496,10 @@ snapshots: expect-type@1.3.0: {} + fast-check@4.8.0: + dependencies: + pure-rand: 8.4.0 + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -4821,6 +4879,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@8.4.0: {} + queue-microtask@1.2.3: {} react-dom@18.3.1(react@18.3.1): @@ -5123,6 +5183,8 @@ snapshots: typescript@5.9.3: {} + uncrypto@0.1.3: {} + undici-types@6.21.0: {} undici-types@7.19.2: {}