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
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ If the re-baselined milestone hits → expand to recurring revenue (Stage 2). If
Every new session, in order:
1. Read `tasks/todo.md` — Stage 1 queue and active tasks
2. Read `tasks/lessons.md` — what went wrong and corrections
3. Check integration health: `curl https://www.houndshield.com/api/health`
3. Check integration health: `curl -H "x-health-token: $HEALTH_DIAGNOSTIC_TOKEN" https://www.houndshield.com/api/health/ready`
(`/api/health` is a bare liveness probe — it returns `{"status":"ok"}` under
every failure condition and is locked that way by
`app/__tests__/health-liveness-contract.test.ts`. The readiness diagnostic is
the token-gated route above, which 404s without the token. Set
`HEALTH_DIAGNOSTIC_TOKEN` in Vercel to turn it on.)
4. Output the HERMES BRIEFING block (below), then start the next `## Active` task.

```
Expand Down Expand Up @@ -115,7 +120,7 @@ Annual discount 17%. 30-day money-back. ONE pricing grid. No Federal tier until

| Integration | Status | Action Required |
|-------------|--------|-----------------|
| Supabase auth + DB | ✅ Wired | Migrations through 037 are in the repo. Applied-to-production status must be verified in the release record. **Release prerequisites:** 028 (shared rate-limit buckets), 031 (auth lockouts), 032 (auth audit trail), 034 (marketing opt-out column before commercial outreach), **035 (hash-only, one-time password-reset codes before reset is enabled)**, **036 (revoke public execution of privileged RPCs)**, and **037 (snapshot_leads — until applied, every free-demo lead is captured by email ONLY and a Resend failure loses it)**. `/api/health` reports missing control stores and reset-code configuration as degraded rather than green. |
| Supabase auth + DB | ✅ Wired | Migrations through 037 are in the repo. Applied-to-production status must be verified in the release record. **Release prerequisites:** 028 (shared rate-limit buckets), 031 (auth lockouts), 032 (auth audit trail), 034 (marketing opt-out column before commercial outreach), **035 (hash-only, one-time password-reset codes before reset is enabled)**, **036 (revoke public execution of privileged RPCs)**, and **037 (snapshot_leads — until applied, every free-demo lead is captured by email ONLY and a Resend failure loses it)**. `/api/health/ready` (token-gated) reports missing control stores and reset-code configuration as degraded rather than green; the public `/api/health` deliberately reports nothing but liveness. |
| Stripe checkout | ✅ Wired | Add a **$499 one-time** report SKU (Stage 1 primary product) |
| Stripe webhook | ⚠️ Verify URL | Confirm `https://www.houndshield.com/api/stripe/webhook` |
| STRIPE_WEBHOOK_SECRET | ❌ Verify | Confirm set in Vercel dashboard |
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ HoundShield is a compliance and data-loss-prevention product for regulated envir

Report privately through either channel:

1. **GitHub Private Vulnerability Reporting** — the [**Security → Report a vulnerability**](../../security/advisories/new) tab on this repository (preferred).
1. **GitHub Private Vulnerability Reporting** — the [**Security → Report a vulnerability**](https://github.com/thecelestialmismatch/HoundShield/security/advisories/new) tab on this repository (preferred).
2. **Email** — `security@houndshield.com` with the details below.

Please include:
Expand Down
4 changes: 2 additions & 2 deletions SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ Use the right channel for the kind of help you need. Public GitHub threads must
| Need | Where to start |
|---|---|
| Installation, testing, or local-development help | [Documentation index](docs/README.md) and [testing guide](docs/TESTING-GUIDE.md) |
| A reproducible defect | [Open a bug report](../../issues/new?template=bug_report.md) after reviewing its privacy guidance |
| A product or workflow proposal | [Open a feature request](../../issues/new?template=feature_request.md) |
| A reproducible defect | [Open a bug report](https://github.com/thecelestialmismatch/HoundShield/issues/new?template=bug_report.md) after reviewing its privacy guidance |
| A product or workflow proposal | [Open a feature request](https://github.com/thecelestialmismatch/HoundShield/issues/new?template=feature_request.md) |
| A suspected vulnerability or data-boundary failure | **Do not open a public issue.** Follow [SECURITY.md](SECURITY.md). |
| Product, deployment, or commercial information | [HoundShield website](https://www.houndshield.com) |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { NextRequest } from "next/server";

/**
* Readiness diagnostic — access control and disclosure boundary.
*
* This route exists because the daily pre-flight in CLAUDE.md
* (`curl .../api/health`) returns green under every failure condition it exists
* to detect, and `/api/admin/health` needs a browser session so it cannot serve
* a terminal. It is the only route in the app that publishes per-control state,
* so the tests that matter are the ones proving it stays shut.
*
* The disclosure assertions are the point. A route that leaks its own existence
* to an unauthenticated caller — a 401 instead of a 404, an error naming the
* expected header — is an oracle worth grinding, and this one reports which
* security controls are currently switched off.
*/

const REAL = "hs_health_0123456789abcdef0123456789abcdef";

vi.mock("@/lib/health/service-status", () => ({
buildHealthReport: vi.fn(async () => ({
services: { database: "connected", payments_webhook: "not_configured" },
degraded: ["payments_webhook"],
})),
}));

async function get(headers: Record<string, string> = {}) {
vi.resetModules();
const { GET } = await import("../route");
return GET(new NextRequest("https://www.houndshield.com/api/health/ready", { headers }));
}

beforeEach(() => {
process.env.HEALTH_DIAGNOSTIC_TOKEN = REAL;
});

afterEach(() => {
delete process.env.HEALTH_DIAGNOSTIC_TOKEN;
});

describe("readiness diagnostic — stays shut", () => {
it("404s an anonymous caller", async () => {
const res = await get();
expect(res.status).toBe(404);
});

it("404s a wrong token, indistinguishably from a missing route", async () => {
const anon = await get();
const wrong = await get({ "x-health-token": "hs_health_wrong" });

expect(wrong.status).toBe(anon.status);
expect(await wrong.json()).toEqual(await anon.json());
});

it("404s when no token is configured, rather than opening the route", async () => {
// Fail closed. The opposite default would publish per-control state on
// every deployment that had not been configured yet — which is every new one.
delete process.env.HEALTH_DIAGNOSTIC_TOKEN;
expect((await get({ "x-health-token": REAL })).status).toBe(404);
});

it("404s when the configured token is blank", async () => {
process.env.HEALTH_DIAGNOSTIC_TOKEN = " ";
expect((await get({ "x-health-token": " " })).status).toBe(404);
});

it("rejects a token that is a prefix of the real one", async () => {
expect((await get({ "x-health-token": REAL.slice(0, -1) })).status).toBe(404);
});

it("rejects a token that merely starts with the real one", async () => {
expect((await get({ "x-health-token": REAL + "x" })).status).toBe(404);
});

it("discloses nothing about the expected credential in the failure body", async () => {
const body = JSON.stringify(await (await get()).json());
expect(body).not.toContain("x-health-token");
expect(body).not.toContain("HEALTH_DIAGNOSTIC_TOKEN");
expect(body).not.toContain("payments_webhook");
expect(body).not.toContain(REAL);
});
});

describe("readiness diagnostic — opens for the operator", () => {
it("reports the degraded controls to a correct token", async () => {
const res = await get({ "x-health-token": REAL });
expect(res.status).toBe(200);

await expect(res.json()).resolves.toEqual({
status: "degraded",
degraded: ["payments_webhook"],
services: { database: "connected", payments_webhook: "not_configured" },
});
});

it("accepts the token as an Authorization bearer, for tooling that cannot set headers", async () => {
const res = await get({ authorization: `Bearer ${REAL}` });
expect(res.status).toBe(200);
});

it("reports ok when nothing is degraded", async () => {
const mod = await import("@/lib/health/service-status");
vi.mocked(mod.buildHealthReport).mockResolvedValueOnce({
services: { database: "connected" },
degraded: [],
});

const res = await get({ "x-health-token": REAL });
await expect(res.json()).resolves.toMatchObject({ status: "ok", degraded: [] });
});

it("is never cached — a stale green is worse than no answer", async () => {
const res = await get({ "x-health-token": REAL });
expect(res.headers.get("Cache-Control")).toBe("no-store");
});

it("also sets no-store on the 404, so a rejection cannot be cached either", async () => {
const res = await get();
expect(res.headers.get("Cache-Control")).toBe("no-store");
});
});

describe("readiness diagnostic — the public probe is untouched", () => {
it("leaves /api/health as a bare liveness response", async () => {
// The boundary this route exists to preserve: the public, unauthenticated
// probe must keep publishing nothing. `health-liveness-contract.test.ts`
// asserts the same thing from the source side; this asserts the behaviour.
vi.resetModules();
const { GET: publicGet } = await import("../../route");
const res = await publicGet();

expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ status: "ok" });
});
});
102 changes: 102 additions & 0 deletions compliance-firewall-agent/app/api/health/ready/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
import { buildHealthReport } from "@/lib/health/service-status";

/**
* GET /api/health/ready — the readiness diagnostic, token-gated.
*
* ─── The gap this closes ───────────────────────────────────────────────────
*
* `CLAUDE.md`'s Session Start Protocol step 3 is
* `curl https://www.houndshield.com/api/health`, and CLAUDE.md states that
* endpoint "reports missing control stores and reset-code configuration as
* degraded rather than green". `docs/gtm/LIVE-PRODUCTION-AUDIT-2026-08-15.md`
* quotes it returning a full sentence about sales being silently lost.
*
* It does not do that. `app/api/health/route.ts` returns `{ status: "ok" }`
* unconditionally, with no branch that can report anything else, and
* `app/__tests__/health-liveness-contract.test.ts` locks it that way on
* purpose — a public, unauthenticated probe should not publish deployment
* topology or per-control state.
*
* Both positions are right, and the contradiction was in the documentation. The
* daily pre-flight had become a check that returns green under every failure
* condition it exists to detect, including the one that has actually cost money:
* `STRIPE_WEBHOOK_SECRET` unset, so a completed $499 purchase records no order,
* sends no receipt and raises no alert.
*
* So the public probe is left exactly as it is, and the capability it cannot
* safely provide lives here instead — behind a shared secret, reachable from a
* terminal. `/api/admin/health` could not serve this: it requires an
* authenticated browser session, so it is unusable from `curl` in a session
* start protocol.
*
* ─── Why this returns 404 and not 401 ──────────────────────────────────────
*
* A wrong token and an unconfigured token both produce the same 404 as a route
* that does not exist. A 401 would confirm the endpoint is real and that the
* header name is right, turning it into an oracle worth grinding. Nothing about
* the deployment is disclosed until the caller already holds the secret.
*
* ─── What it reports ───────────────────────────────────────────────────────
*
* `lib/health/service-status.ts`, whose header states its output is
* "VALUE-FREE, ALWAYS … derived from the SHAPE or PRESENCE of configuration,
* never its content". That module was written for exactly this and had been
* orphaned — its only remaining consumer was its own test file.
*/

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

/** Header the operator sends. Also accepted as `Authorization: Bearer <token>`. */
const TOKEN_HEADER = "x-health-token";

/** Indistinguishable from a route that does not exist. */
function notFound(): NextResponse {
return NextResponse.json(
{ error: "Not found" },
{ status: 404, headers: { "Cache-Control": "no-store" } }
);
}

/**
* Constant-time compare. `a !== b` short-circuits on the first differing byte,
* which leaks the token prefix to anyone who can time the response. Lengths are
* compared first because timingSafeEqual throws on a length mismatch; the
* length of the token is not the secret.
*/
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a, "utf8");
const bb = Buffer.from(b, "utf8");
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}

function presentedToken(request: NextRequest): string {
const header = request.headers.get(TOKEN_HEADER);
if (header) return header.trim();
const auth = request.headers.get("authorization") ?? "";
return auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
}

export async function GET(request: NextRequest) {
const expected = (process.env.HEALTH_DIAGNOSTIC_TOKEN ?? "").trim();

// Fail closed. An unset token disables the route rather than opening it —
// the opposite default would publish per-control state on every deployment
// that had not yet been configured, which is every new one.
if (expected.length === 0) return notFound();
if (!safeEqual(presentedToken(request), expected)) return notFound();

const report = await buildHealthReport();

return NextResponse.json(
{
status: report.degraded.length === 0 ? "ok" : "degraded",
degraded: report.degraded,
services: report.services,
},
{ headers: { "Cache-Control": "no-store" } }
);
}
Loading
Loading