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
4 changes: 4 additions & 0 deletions .github/workflows/uptime.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
16 changes: 16 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
80 changes: 63 additions & 17 deletions scripts/uptime-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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,
},
Expand Down Expand Up @@ -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),
});
}
}
Expand All @@ -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}`);
}
}

Expand Down Expand Up @@ -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.",
"",
];
Expand Down
127 changes: 126 additions & 1 deletion scripts/uptime-check.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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/);
});
});