diff --git a/.github/workflows/uptime.yml b/.github/workflows/uptime.yml index eb0f0662..34486a06 100644 --- a/.github/workflows/uptime.yml +++ b/.github/workflows/uptime.yml @@ -27,6 +27,10 @@ jobs: - name: Run checks id: uptime env: + # Least-privilege API keys for the synthetic write check (issue + # 8.9). Secrets, not vars — these authenticate a write. + UPTIME_API_KEY: ${{ secrets.UPTIME_API_KEY }} + UPTIME_MAINNET_API_KEY: ${{ secrets.UPTIME_MAINNET_API_KEY }} # Mainnet is only checked once this is actually set — see # docs/RUNBOOK.md "Uptime monitoring". No default: an unset value # here must mean "not configured", never "check testnet again". diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..72bcb7e0 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,16 @@ +# Gitleaks findings that are false positives, by fingerprint +# (commit:file:rule:line). Only add entries here for values verified as +# non-credentials — never to silence a real leak. + +# Placeholder API keys in the uptime-check unit tests (PR #181, issue 8.9). +# `generic-api-key` matched fixture strings like "ak_live_test123" passed to +# checkSyntheticLink. A real key is `ak_live_` followed by 32 base62 chars +# (apps/api/src/services/api-keys.ts), so these are far too short to be one, +# and they were only ever handed to a mocked fetch. The fixtures on the +# current tree were renamed so the rule no longer matches; these entries +# cover the historical commit, which gitleaks still scans. +558c075f26a6969d82728db03e14993ae87749a9:scripts/uptime-check.test.ts:generic-api-key:41 +558c075f26a6969d82728db03e14993ae87749a9:scripts/uptime-check.test.ts:generic-api-key:74 +558c075f26a6969d82728db03e14993ae87749a9:scripts/uptime-check.test.ts:generic-api-key:89 +558c075f26a6969d82728db03e14993ae87749a9:scripts/uptime-check.test.ts:generic-api-key:101 +558c075f26a6969d82728db03e14993ae87749a9:scripts/uptime-check.test.ts:generic-api-key:114 diff --git a/docs/STATUS.md b/docs/STATUS.md index dc22fdc1..236a6491 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,8 @@ # Status +> **Last regenerated:** 2026-08-18T06:18:10.349Z +> ⚠️ This page is stale — the uptime schedule was disabled in `a0f06d1`. + Generated by `.github/workflows/uptime.yml` (every 5 minutes) — do not edit by hand. ## API diff --git a/scripts/uptime-check.mjs b/scripts/uptime-check.mjs index de7379df..28082808 100644 --- a/scripts/uptime-check.mjs +++ b/scripts/uptime-check.mjs @@ -50,9 +50,9 @@ export function buildEnvironments(vars = process.env) { // public testnet deploy so `pnpm sweep` works with zero setup. apiUrl: envUrl(vars, "UPTIME_TESTNET_API_URL") ?? envUrl(vars, "UPTIME_API_URL") ?? "https://quay-api.onrender.com", webUrl: envUrl(vars, "UPTIME_TESTNET_WEB_URL") ?? envUrl(vars, "UPTIME_WEB_URL") ?? "https://quay-web.vercel.app", - // Leaves a tiny throwaway link behind on every successful run (title - // "uptime-check") — proves the public write path works. Known - // trade-off for a demo-scale DB; see checkSyntheticLink. + // Dedicated least-privilege key (links:read + links:write) for the + // synthetic write check — POST /links has required auth since 6.x. + apiKey: envUrl(vars, "UPTIME_API_KEY"), syntheticLink: true, prefixIds: false, }, @@ -61,11 +61,14 @@ export function buildEnvironments(vars = process.env) { label: "Mainnet", apiUrl: envUrl(vars, "UPTIME_MAINNET_API_URL"), webUrl: envUrl(vars, "UPTIME_MAINNET_WEB_URL"), - // Off unless explicitly opted into: this would leave a throwaway row in - // the REAL production database on every successful run, and — unlike - // testnet — POST /links there has no authorization story yet (issue - // #163 tracks a least-privilege API key for this). Don't hit live - // infrastructure with an unauthenticated write until that lands. + // Its own key, never testnet's — a testnet key cannot create links on + // mainnet, and sharing one would defeat the least-privilege point. + apiKey: envUrl(vars, "UPTIME_MAINNET_API_KEY"), + // Off unless explicitly opted into: this writes a throwaway row into the + // REAL production database on every successful run. Issue 8.9 gave the + // check a scoped key and cleanup, so it is now safe to enable — but it + // stays opt-in because "safe to run" is not the same as "should run + // against production without the operator deciding to". syntheticLink: envUrl(vars, "UPTIME_MAINNET_SYNTHETIC_CHECK") === "1", prefixIds: true, }, @@ -104,7 +107,7 @@ export function buildTargets(environments) { kind: "Create-link (synthetic)", label: `${env.label} — Create-link (synthetic)`, env, - check: () => checkSyntheticLink(env.apiUrl), + check: () => checkSyntheticLink(env.apiUrl, env.apiKey), }); } } @@ -116,17 +119,55 @@ async function checkGet(url) { if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`); } -// Leaves a tiny throwaway link behind on every successful run (title -// "uptime-check", filterable/prunable later) — the point is proving the public -// write path works, not cleanliness. Known trade-off for a demo-scale DB. -async function checkSyntheticLink(apiUrl) { - const res = await fetchWithTimeout(`${apiUrl}/links`, { +/** + * Synthetic create-link check: proves the authenticated public write path + * works end to end (issue 8.9). + * + * `POST /links` has required a seller session or a scoped API key since 6.x, + * so this check 401'd on every run for reasons that had nothing to do with + * availability — a permanent false negative sitting next to two real checks. + * It now sends a dedicated least-privilege key (`links:read` + `links:write`). + * + * The created link is cancelled immediately afterwards so synthetic rows stop + * accumulating. Cleanup failure is logged, not fatal: the write path — the + * thing being measured — already succeeded by that point, and failing the + * probe over cleanup would report an outage that isn't one. + */ +export async function checkSyntheticLink(apiUrl, apiKey, fetchImpl = fetchWithTimeout) { + const headers = { "content-type": "application/json" }; + if (apiKey) headers.authorization = `Bearer ${apiKey}`; + + const createRes = await fetchImpl(`${apiUrl}/links`, { method: "POST", - headers: { "content-type": "application/json" }, + headers, body: JSON.stringify({ title: "uptime-check", amount: "0.0000001", assetCode: "XLM" }), }); - if (res.status !== 201) { - throw new Error(`POST ${apiUrl}/links -> HTTP ${res.status} (expected 201)`); + + if (createRes.status === 401 && !apiKey) { + throw new Error( + `POST ${apiUrl}/links -> HTTP 401 and no API key is configured. ` + + "Add a least-privilege API key (links:read + links:write) as the UPTIME_API_KEY repo secret " + + "(UPTIME_MAINNET_API_KEY for mainnet).", + ); + } + + if (createRes.status !== 201) { + throw new Error(`POST ${apiUrl}/links -> HTTP ${createRes.status} (expected 201)`); + } + + try { + const body = await createRes.json(); + const linkId = body?.link?.id; + if (!linkId) return; + const cancelRes = await fetchImpl(`${apiUrl}/links/${linkId}/cancel`, { + method: "POST", + headers, + }); + if (!cancelRes.ok) { + console.warn(`[uptime] could not cancel synthetic link ${linkId} (HTTP ${cancelRes.status})`); + } + } catch (err) { + console.warn(`[uptime] synthetic link cleanup failed: ${err instanceof Error ? err.message : err}`); } } @@ -207,6 +248,11 @@ export function renderStatusMd(state, environments) { const lines = [ "# Status", "", + // Issue 8.9: regeneration stopped when the schedule was disabled, so this + // page kept showing an old green. A visible timestamp makes a stale page + // read as stale rather than as healthy. + `> **Last regenerated:** ${new Date().toISOString()}`, + "", "Generated by `.github/workflows/uptime.yml` (every 5 minutes) — do not edit by hand.", "", ]; diff --git a/scripts/uptime-check.test.ts b/scripts/uptime-check.test.ts index 71b8291c..1f76f531 100644 --- a/scripts/uptime-check.test.ts +++ b/scripts/uptime-check.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { activeEnvironments, buildEnvironments, buildTargets, recordResult, renderStatusMd, uptimePct } from "./uptime-check.mjs"; +import { activeEnvironments, buildEnvironments, buildTargets, checkSyntheticLink, recordResult, renderStatusMd, uptimePct } from "./uptime-check.mjs"; describe("buildEnvironments", () => { it("testnet always defaults to the public testnet deploy, unprefixed", () => { @@ -137,3 +137,128 @@ describe("renderStatusMd", () => { expect(md).toContain("connection refused"); }); }); + +// --------------------------------------------------------------------------- +// Synthetic create-link check (issue 8.9) +// +// POST /links has required a seller session or a scoped API key since 6.x, so +// this check 401'd on every run — a permanent false negative sitting beside +// two real checks. These exercise the function the CLI actually calls, with +// fetch injected, rather than a parallel copy that could drift from it. +// --------------------------------------------------------------------------- + +// Placeholder credentials. Deliberately not shaped like a real key (which is +// `ak_live_` + 32 base62 chars) so a secret scanner has nothing to match on. +const FAKE_KEY = "not-a-real-key"; +const FAKE_REVOKED_KEY = "not-a-real-revoked-key"; +const FAKE_MAINNET_KEY = "not-a-real-mainnet-key"; + +function jsonResponse(status: number, body?: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body ?? {}, + } as unknown as Response; +} + +describe("checkSyntheticLink", () => { + const API = "https://api.example.com"; + + it("sends the key as a bearer token and cancels the link it created", async () => { + const calls: Array<[string, any]> = []; + const fakeFetch = async (url: string, opts: any) => { + calls.push([url, opts]); + return calls.length === 1 + ? jsonResponse(201, { link: { id: "lnk_uptime_1" } }) + : jsonResponse(200, { link: { id: "lnk_uptime_1", status: "cancelled" } }); + }; + + await checkSyntheticLink(API, FAKE_KEY, fakeFetch); + + expect(calls).toHaveLength(2); + const [createUrl, createOpts] = calls[0]!; + expect(createUrl).toBe(`${API}/links`); + expect(createOpts.headers.authorization).toBe(`Bearer ${FAKE_KEY}`); + expect(JSON.parse(createOpts.body)).toEqual({ + title: "uptime-check", + amount: "0.0000001", + assetCode: "XLM", + }); + + // Cleanup: the throwaway row must not accumulate. + const [cancelUrl, cancelOpts] = calls[1]!; + expect(cancelUrl).toBe(`${API}/links/lnk_uptime_1/cancel`); + expect(cancelOpts.headers.authorization).toBe(`Bearer ${FAKE_KEY}`); + }); + + it("sends no Authorization header when no key is configured", async () => { + const calls: Array<[string, any]> = []; + const fakeFetch = async (url: string, opts: any) => { + calls.push([url, opts]); + return jsonResponse(401); + }; + + await expect(checkSyntheticLink(API, null, fakeFetch)).rejects.toThrow(/no API key is configured/); + expect(calls[0]![1].headers.authorization).toBeUndefined(); + }); + + it("names the missing secret on a 401, instead of reporting a bare HTTP 401", async () => { + const fakeFetch = async () => jsonResponse(401); + await expect(checkSyntheticLink(API, null, fakeFetch)).rejects.toThrow(/UPTIME_API_KEY/); + }); + + it("still reports a genuine 401 as a failure when a key IS configured", async () => { + // A configured-but-rejected key is a real problem — a revoked or + // wrong-scope key must not be reported as a missing-secret misconfiguration. + const fakeFetch = async () => jsonResponse(401); + await expect(checkSyntheticLink(API, FAKE_REVOKED_KEY, fakeFetch)).rejects.toThrow(/expected 201/); + }); + + it("fails when the write path is genuinely broken", async () => { + const fakeFetch = async () => jsonResponse(500); + await expect(checkSyntheticLink(API, FAKE_KEY, fakeFetch)).rejects.toThrow(/HTTP 500 \(expected 201\)/); + }); + + it("does not fail the probe when only cleanup fails", async () => { + // The write path — the thing being measured — already succeeded. Reporting + // an outage because cancellation 500'd would be a false positive. + let n = 0; + const fakeFetch = async () => { + n += 1; + return n === 1 ? jsonResponse(201, { link: { id: "lnk_1" } }) : jsonResponse(500); + }; + await expect(checkSyntheticLink(API, FAKE_KEY, fakeFetch)).resolves.toBeUndefined(); + }); + + it("tolerates a 201 body with no link id", async () => { + let n = 0; + const fakeFetch = async () => { + n += 1; + return n === 1 ? jsonResponse(201, {}) : jsonResponse(200); + }; + await expect(checkSyntheticLink(API, FAKE_KEY, fakeFetch)).resolves.toBeUndefined(); + expect(n).toBe(1); // no cancel attempted + }); +}); + +describe("uptime API keys", () => { + it("reads a per-environment key, never sharing testnet's with mainnet", () => { + const [testnet, mainnet] = buildEnvironments({ + UPTIME_API_KEY: FAKE_KEY, + UPTIME_MAINNET_API_KEY: FAKE_MAINNET_KEY, + }); + expect(testnet.apiKey).toBe(FAKE_KEY); + expect(mainnet.apiKey).toBe(FAKE_MAINNET_KEY); + + const [onlyTestnetKey] = buildEnvironments({ UPTIME_API_KEY: FAKE_KEY }); + expect(onlyTestnetKey.apiKey).toBe(FAKE_KEY); + expect(buildEnvironments({ UPTIME_API_KEY: FAKE_KEY })[1]!.apiKey).toBeFalsy(); + }); +}); + +describe("renderStatusMd staleness", () => { + it("stamps when it was last regenerated, so a stale page reads as stale", () => { + const md = renderStatusMd({ targets: {} }, buildEnvironments({})); + expect(md).toMatch(/> \*\*Last regenerated:\*\* \d{4}-\d{2}-\d{2}T[\d:.]+Z/); + }); +});