Repository navigation
feat: add standalone Cap status app #2475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
3d06b9a
feat: add standalone Cap status monitoring app
richiemcilroy 6224868
improve: simplify status page headings
richiemcilroy 2ff2af0
fix: refresh status freshness when returning to a tab
richiemcilroy f4eb55d
improve: check service health every five minutes
richiemcilroy 6807174
fix: isolate status development and protect cached reads
richiemcilroy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| name: Status app | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - "apps/status/**" | ||
| - "bun.lock" | ||
| - ".github/workflows/status.yml" | ||
| push: | ||
| branches: [main] | ||
| paths: | ||
| - "apps/status/**" | ||
| - "bun.lock" | ||
| - ".github/workflows/status.yml" | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| verify: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version: "1.4.0" | ||
| - run: bun install --frozen-lockfile | ||
| - run: bun run --cwd apps/status test | ||
| - run: bun run --cwd apps/status build | ||
| env: | ||
| NEXT_TELEMETRY_DISABLED: "1" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| UPSTASH_REDIS_REST_URL= | ||
| UPSTASH_REDIS_REST_TOKEN= | ||
| CRON_SECRET= | ||
| STATUS_WEBSITE_URL=https://cap.so | ||
| STATUS_API_URL=https://cap.so/api/auth/providers | ||
| STATUS_SHARING_URL= | ||
| STATUS_STORAGE_URL= | ||
| STATUS_MEDIA_URL= | ||
| STATUS_RENDER_URL= |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| .next/ | ||
| next-env.d.ts |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # Cap Status | ||
|
|
||
| An independent Next.js app for `status.cap.so`. It reuses the current landing page’s Instrument Sans, Source Serif 4, DM Mono, cloud artwork, and colour palette. It has no dependency on the main web app, its database, or its deployment. | ||
|
|
||
| ## Deploy on Vercel | ||
|
|
||
| 1. Import this repository into a **separate Vercel project**, with root directory **`apps/status`**, framework **Next.js**, and build command **`bun run build`**. Keep access to the repository root enabled so Vercel uses the existing Bun workspace and lockfile. Use Bun 1.4.0. Do not attach the main site’s environment variables. | ||
| 2. Attach a dedicated Upstash Redis database through the Vercel Marketplace. Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` on the production environment. Keep preview data in a separate database, or leave preview storage unconfigured. | ||
| 3. Set a random `CRON_SECRET` of at least 32 bytes on production. Vercel sends it as the cron’s Bearer token. Do not prefix any of these variables with `NEXT_PUBLIC_`. | ||
| 4. Configure the production service URLs below. Deploy on a plan supporting **five-minute cron jobs** (Vercel Pro or higher), then add `status.cap.so` to this project and point its DNS at Vercel. The page starts with honest “no data” states until checks run. | ||
| 5. Verify the first `/api/cron/check` invocation succeeds in the Vercel logs and `/api/status` contains current observations for every configured service. Set an **external dead-man monitor** to alert when `/api/status` has an old `checkedAt` (over 15 minutes), returns non-200, or cannot be reached. This detects a failed scheduler, Redis, or the status deployment itself. | ||
| 6. Before public launch, configure a Vercel WAF rate limit for `/api/status` (GET and HEAD). Start with 300 requests per minute per IP in log mode, check expected traffic, then enforce with a 429 response. The public API rejects query parameters before accessing Redis, preventing cache-busting URLs from consuming storage requests. The WAF rule protects function usage as well and is configured on the separate status project, not the main site. | ||
|
|
||
| No production deployment, DNS changes, external monitors, or storage resources are created by this PR. | ||
|
|
||
| ## Service coverage | ||
|
|
||
| All endpoints must be direct HTTPS URLs returning 2xx; redirects are intentionally rejected so a sign-in redirect or protection page cannot count as a healthy service. Use dedicated, public, read-only canaries; never use a customer’s private recording or a signed URL that expires. | ||
|
|
||
| | Environment variable | Probe | Coverage | | ||
| | --- | --- | --- | | ||
| | `STATUS_WEBSITE_URL` | HEAD; defaults to `https://cap.so` | Public website reachability | | ||
| | `STATUS_API_URL` | GET; defaults to `https://cap.so/api/auth/providers` | Authentication API reachability and a real provider payload; does not simulate sign-in or prove database health | | ||
| | `STATUS_SHARING_URL` | HEAD; required | A permanent public test recording’s share page returning 200 | | ||
| | `STATUS_STORAGE_URL` | HEAD; required | A small, permanent canary object on the production storage/CDN path; does not test uploads | | ||
| | `STATUS_MEDIA_URL` | GET; required | Media server `/health`, including `status === "ok"` rather than HTTP status alone | | ||
| | `STATUS_RENDER_URL` | GET; required | Render coordinator `/health`, requiring `ok`, active workers, and render slots | | ||
|
|
||
| Missing or invalid configuration is shown as unknown, never operational. Add other services to `lib/services.ts` and their environment variables to `.env.example`. Each service gets its own history and automatic incidents. The `/health` URLs are existing endpoints; this app adds no instrumentation, middleware, migrations, or code to Cap’s production services. | ||
|
|
||
| These are availability checks, not end-to-end recording/upload/playback tests. A cache-served canary confirms the serving path, not every downstream dependency. Keep functional synthetic tests and provider alerting alongside this page. | ||
|
|
||
| ## Monitoring and persistence | ||
|
|
||
| - Vercel cron runs every five minutes **only on production**. `/api/cron/check` rejects unauthenticated calls. Preview deployments cannot run the collector. | ||
| - Six lightweight probes run concurrently, each with an eight-second deadline. Healthy responses over three seconds are degraded. An unhealthy probe is retried once immediately. Normally 72 requests per hour reach Cap; retries can increase that to 144 per hour, independent of page traffic. HEAD probes do not download recordings; GET probes use only small health JSON. | ||
| - A 55-second Redis lease prevents overlapping collectors. The function deadline is 50 seconds. Saving observations and releasing the lease is one atomic Lua operation conditional on lease ownership. A crashed collector’s lease expires. Duplicate invocations in the same five-minute UTC interval do not double-count. | ||
| - A first confirmed unhealthy sample shows degraded/unknown status. Two consecutive unhealthy samples open an incident, typically 5–10 minutes after a failure begins; two consecutive successful samples close it. After a monitoring gap, consecutive counters reset. An unobserved gap never resolves an incident. | ||
| - Redis stores 90 UTC calendar days of daily observed success/degradation/failure counts and all ongoing incidents and up to 500 recently resolved incidents. Empty days remain empty. Uptime is successful observed checks divided by all observed checks; it is not a promise about unobserved time. Hover, focus, or tap a day to see the sample count. | ||
| - Visitors read only `/api/status`, cached at the CDN for 30 seconds. The visible page refreshes once a minute and stops polling in hidden tabs. It independently marks observations older than 15 minutes unknown. Storage failures show monitoring unavailable; they never fabricate healthy data. No service URLs, health payloads, or credentials appear in the public snapshot. | ||
|
|
||
| The status page and its scheduler share Vercel. The independent dead-man monitor is needed to notify operators of a Vercel-wide outage. This version records automatic incidents; notifications and manually written incident updates are outside its scope. | ||
|
|
||
| ## Local checks | ||
|
|
||
| From the repository root, install with `bun install --frozen-lockfile`. Then: | ||
|
|
||
| ```sh | ||
| bun run --cwd apps/status test | ||
| bun run --cwd apps/status typecheck | ||
| bun run --cwd apps/status build | ||
| ``` | ||
|
|
||
| Copy `.env.example` to `.env.local` in this app for local configuration. Use a separate development Redis database. `bun run --cwd apps/status start` serves the production build on port 3003; `bun run --cwd apps/status dev` uses the same port. The status app is excluded from the root development commands, including the Windows wrapper, so it runs only when explicitly started and cannot take the desktop harness's port 3002. Respect the repository’s existing-server rules when starting a local server. | ||
|
|
||
| ## Screenshots | ||
|
|
||
| [Desktop preview](screenshots/desktop.png) · [Mobile preview](screenshots/mobile.png) | ||
|
|
||
| These screenshots show the production build with sample monitoring responses, not live service history. The browser checks also cover missing storage, stale observations, an active outage, incident display, service expansion, and keyboard navigation through daily uptime. Automated WCAG 2.1 A/AA checks pass for the normal and outage views. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import { randomUUID, timingSafeEqual } from "node:crypto"; | ||
| import { probe } from "../../../../lib/probe"; | ||
| import { SERVICES, serviceUrl } from "../../../../lib/services"; | ||
| import { applyResult, CHECK_INTERVAL_MS } from "../../../../lib/state"; | ||
| import { | ||
| COMMIT_SCRIPT, | ||
| getRedis, | ||
| LOCK_KEY, | ||
| RELEASE_SCRIPT, | ||
| readState, | ||
| STATE_KEY, | ||
| } from "../../../../lib/store"; | ||
|
|
||
| export const maxDuration = 50; | ||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| export async function GET(request: Request) { | ||
| const secret = process.env.CRON_SECRET; | ||
| const supplied = Buffer.from(request.headers.get("authorization") ?? ""); | ||
| const expected = Buffer.from(`Bearer ${secret}`); | ||
| if ( | ||
| !secret || | ||
| supplied.length !== expected.length || | ||
| !timingSafeEqual(supplied, expected) | ||
| ) { | ||
| return new Response("Unauthorized", { status: 401 }); | ||
| } | ||
| if (process.env.VERCEL_ENV && process.env.VERCEL_ENV !== "production") { | ||
| return Response.json({ skipped: "Preview deployment" }); | ||
| } | ||
| try { | ||
| const redis = getRedis(); | ||
| const owner = randomUUID(); | ||
| if (!(await redis.set(LOCK_KEY, owner, { nx: true, ex: 55 }))) | ||
| return Response.json({ skipped: "Check already running" }); | ||
| try { | ||
| const state = await readState(redis); | ||
| const now = Date.now(); | ||
| if ( | ||
| state.checkedAt && | ||
| Math.floor(Date.parse(state.checkedAt) / CHECK_INTERVAL_MS) >= | ||
| Math.floor(now / CHECK_INTERVAL_MS) | ||
| ) { | ||
| return Response.json({ skipped: "Check interval already sampled" }); | ||
| } | ||
| const results = await Promise.all( | ||
| SERVICES.map(async (service) => { | ||
| try { | ||
| const url = serviceUrl(service); | ||
| return { | ||
| id: service.id, | ||
| result: url ? await probe(service, url) : null, | ||
| }; | ||
| } catch { | ||
| console.error(`Invalid monitor configuration: ${service.id}`); | ||
| return { id: service.id, result: null }; | ||
| } | ||
| }), | ||
| ); | ||
| for (const { id, result } of results) applyResult(state, id, result, now); | ||
| state.checkedAt = new Date(now).toISOString(); | ||
| const committed = await redis.eval( | ||
| COMMIT_SCRIPT, | ||
| [LOCK_KEY, STATE_KEY], | ||
| [owner, JSON.stringify(state)], | ||
| ); | ||
| if (committed !== 1) throw new Error("Monitor lease expired"); | ||
| return Response.json({ | ||
| checkedAt: state.checkedAt, | ||
| checked: results.filter((entry) => entry.result).length, | ||
| }); | ||
| } finally { | ||
| await redis.eval(RELEASE_SCRIPT, [LOCK_KEY], [owner]); | ||
| } | ||
| } catch { | ||
| console.error("Status check could not be saved"); | ||
| return Response.json({ error: "Monitoring unavailable" }, { status: 503 }); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { emptyState, publicSnapshot } from "../../../lib/state"; | ||
| import { getRedis, readState } from "../../../lib/store"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| export async function GET(request: Request) { | ||
| if (new URL(request.url).search) { | ||
| return Response.json( | ||
| { error: "Query parameters are not supported" }, | ||
| { status: 400, headers: { "Cache-Control": "no-store" } }, | ||
| ); | ||
| } | ||
| try { | ||
| return Response.json(publicSnapshot(await readState(getRedis())), { | ||
| headers: { | ||
| "Cache-Control": | ||
| "public, max-age=0, s-maxage=30, stale-while-revalidate=30", | ||
| }, | ||
| }); | ||
| } catch { | ||
| console.error("Status snapshot unavailable"); | ||
| return Response.json(publicSnapshot(emptyState()), { | ||
| status: 503, | ||
| headers: { "Cache-Control": "no-store" }, | ||
| }); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Unique query strings bypass the status API cache and repeatedly consume Redis and function resources.
Unique query strings bypass the 30-second per-URL cache; each miss reads the dedicated Redis store.
Reject query strings and apply an edge rate limit before the Redis-backed handler.
AI prompt
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed the cache-busting path in 6807174: requests with query parameters return 400 before getRedis() or readState(). The regression test covers multiple unique and encoded query strings and asserts zero Redis commands. The production HTTP check confirms the same behavior. The separate status project’s WAF rate-limit setup is documented as a requirement before public launch; no production firewall configuration has been changed by this PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The developer’s fix directly remediates the reported cache bypass by rejecting requests with query parameters before any Redis interaction, and the regression tests verify that no Redis commands are issued for cache-busting URLs. Treating edge rate limiting as an infrastructure/WAF prerequisite documented for public launch is a reasonable separation of concerns, so this reply satisfactorily addresses the finding.