Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/status.yml
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"
9 changes: 9 additions & 0 deletions apps/status/.env.example
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=
2 changes: 2 additions & 0 deletions apps/status/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.next/
next-env.d.ts
60 changes: 60 additions & 0 deletions apps/status/README.md
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.
79 changes: 79 additions & 0 deletions apps/status/app/api/cron/check/route.ts
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 });
}
}
27 changes: 27 additions & 0 deletions apps/status/app/api/status/route.ts
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",

Copy link
Copy Markdown

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
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="apps/status/app/api/status/route.ts">
<violation number="1" location="apps/status/app/api/status/route.ts:11">
<priority>P2</priority>
<title>Unique query strings bypass the status API cache and repeatedly consume Redis and function resources.</title>
<evidence>The new public GET handler reads the dedicated Redis state at line 8, then sets `Cache-Control: public, max-age=0, s-maxage=30, stale-while-revalidate=30` at line 11. The handler does not inspect or reject query parameters, so an unauthenticated caller can request `/api/status?x=&lt;unique-value&gt;` for each request. Vercel's official CDN documentation says function responses are cached when they set `s-maxage` (https://vercel.com/docs/caching/cdn-cache), and its cache-key documentation says the request URL is part of the cache key, with query strings ignored only for static files (https://vercel.com/docs/caching/cdn-cache/purge). These distinct function URLs therefore create distinct cache entries rather than reusing the intended 30-second cache. Each cache miss runs `readState(getRedis())` and performs a Redis GET; `publicSnapshot` then projects six services across 90 history days (`apps/status/lib/state.ts:152-175`). The app provides no query normalization/rejection or per-caller rate limit before the Redis read. The package pins Next.js 16.3.0 (`apps/status/package.json:15`); Vercel documents the function-response caching behavior used here. Repeated unique requests can consume the status deployment's function invocations and dedicated Redis request quota, affecting availability and/or exhausting usage limits.</evidence>
<recommendation>Reject requests with any query string before calling `getRedis()` and enforce an edge-level rate limit or request quota for `/api/status`. Keep the public cache on a single canonical URL so caller-controlled URL variants cannot force repeated Redis reads.</recommendation>
</violation>
</file>

Copy link
Copy Markdown
Member Author

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.

Copy link
Copy Markdown

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.

},
});
} catch {
console.error("Status snapshot unavailable");
return Response.json(publicSnapshot(emptyState()), {
status: 503,
headers: { "Cache-Control": "no-store" },
});
}
}
Loading
Loading