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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ NEXT_PUBLIC_STELLAR_NETWORK=testnet
NEXT_PUBLIC_HORIZON_TESTNET_URL=https://horizon-testnet.stellar.org
NEXT_PUBLIC_HORIZON_MAINNET_URL=https://horizon.stellar.org
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Optional service status, see docs/PUBLIC_STATUS.md. Unset means normal.
# UTIX_SERVICE_MODE=maintenance
# UTIX_SERVICE_MESSAGE=Scheduled maintenance; some tools may be briefly unavailable.
# UTIX_SERVICE_UNTIL=2026-03-10T13:00:00.000Z
22 changes: 22 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,25 @@ npm run build
- Public key validation and public Horizon lookups are in scope.
- Friendbot usage is testnet-only and has no real asset value.
- The app does not store wallet keys or submit signed transactions.
- `GET /api/status` and the site banner publish only an allow-listed public notice; operator messages containing secrets, tokens, credential URLs, internal addresses or stack frames are refused (see [docs/PUBLIC_STATUS.md](./docs/PUBLIC_STATUS.md)).

## Read-only threat boundaries

Every tool is an independently contributed slice under `features/`, so the
read-only promise is verified per slice rather than assumed:

- Each slice's `README.md` has a `## Threat boundary` section listing its
**data sources**, its **outbound requests**, and the **proof that it cannot
sign or submit** a transaction. See rule 15 in
[docs/FEATURE_CONTRACT.md](./docs/FEATURE_CONTRACT.md) for the format and
the reviewer checklist, and
[features/trustline-checker/README.md](./features/trustline-checker/README.md)
for a worked example.
- `npm run verify:features` (run in CI) fails a slice whose section is missing
or unfinished, and fails any slice whose runtime code calls a signing or
submission API. Slices written before the rule are tracked in
`scripts/threat-boundary-migration.json` until they are documented.

When reviewing a security-relevant change, read the slice's threat boundary
against its `lib/` and `msw/handlers.ts`: every request the code makes should
be listed, and every listed request should be a public, read-only endpoint.
10 changes: 10 additions & 0 deletions app/api/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { buildStatusResponse } from "@/core/status/service-config";

// Read the environment and the clock per request, never at build time.
export const dynamic = "force-dynamic";

/** Public, read-only status: the service notice plus the schema-backed feed. */
export function GET(): Response {
const response = buildStatusResponse(process.env, Date.now());
return Response.json(response.body, { status: response.status, headers: response.headers });
}
5 changes: 5 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import type { Metadata } from "next";
import "./globals.css";
import { AppShell } from "@/core/layout/AppShell";

// The service-status banner and the paused-tool gate are resolved at render
// time. Re-rendering at most once a minute lets a scheduled `since` or an
// expired `until` (core/status/service-config.ts) take effect without a deploy.
export const revalidate = 60;

export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"),
title: "RevyHubX",
Expand Down
13 changes: 13 additions & 0 deletions app/tools/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { notFound } from "next/navigation";
import { findFeature, loadFeaturePanel } from "@/core/registry/registry";
import { featureSlugs, findManifest } from "@/core/registry/manifests";
import { FeatureShell } from "@/core/ui/FeatureShell";
import { currentServiceNotice } from "@/core/status/service-config";
import { PausedToolNotice } from "@/core/status/ServiceStatusBanner";

interface RouteParams {
params: Promise<{ slug: string }>;
Expand Down Expand Up @@ -41,6 +43,17 @@ export default async function ToolPage({ params }: RouteParams) {
if (!feature) notFound();

const { manifest } = feature;

// A pause is a kill switch: the panel is never mounted, so no tool can make
// a request until the operator lifts it.
if (currentServiceNotice().mode === "paused") {
return (
<FeatureShell manifest={manifest}>
<PausedToolNotice />
</FeatureShell>
);
}

const loaded = await loadFeaturePanel(feature);

if (!loaded.ok) {
Expand Down
155 changes: 155 additions & 0 deletions core/__tests__/threatBoundaryDocs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import {
THREAT_BOUNDARY_FIELDS,
evaluateThreatBoundaryDoc,
findSigningCalls,
loadMigrationLedger,
verifySlice
} from "../../scripts/verify-features.mjs";

const root = process.cwd();
const featuresDir = path.join(root, "features");
const ledgerPath = path.join(root, "scripts/threat-boundary-migration.json");

const slugs = readdirSync(featuresDir, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith(".") && !dirent.name.startsWith("_"))
.map((dirent) => dirent.name)
.sort();

const documented = [
"# Tool",
"",
"## Threat boundary",
"",
"- **Data sources:** the form field.",
"- **Outbound requests:** None — runs entirely in the browser.",
"- **Cannot sign or submit:** the schema rejects `S…` secrets.",
"",
"## Files"
].join("\n");

describe("threat-boundary documentation check", () => {
it("passes a README that answers every field", () => {
expect(evaluateThreatBoundaryDoc("tool", documented)).toEqual({
slug: "tool",
ok: true,
hasSection: true,
missing: []
});
});

it("fails a README without the section, naming every field", () => {
const result = evaluateThreatBoundaryDoc("tool", "# Tool\n\n## Safety\n\nRead-only.");
expect(result.ok).toBe(false);
expect(result.hasSection).toBe(false);
expect(result.missing).toEqual([...THREAT_BOUNDARY_FIELDS]);
});

it("treats a missing README the same as a missing section", () => {
expect(evaluateThreatBoundaryDoc("tool", undefined).missing).toEqual([...THREAT_BOUNDARY_FIELDS]);
});

it("does not accept the scaffold's TODO placeholders as answers", () => {
const scaffolded = documented.replace("the form field.", "TODO: every input the tool reads.");
expect(evaluateThreatBoundaryDoc("tool", scaffolded).missing).toEqual(["Data sources"]);
});

it("does not accept an empty answer", () => {
const empty = documented.replace("**Outbound requests:** None — runs entirely in the browser.", "**Outbound requests:**");
expect(evaluateThreatBoundaryDoc("tool", empty).missing).toEqual(["Outbound requests"]);
});

it("only reads fields inside the section, not elsewhere in the README", () => {
const outside = [
"# Tool",
"- **Data sources:** the form field.",
"## Threat boundary",
"- **Outbound requests:** None.",
"- **Cannot sign or submit:** no secrets accepted."
].join("\n");
expect(evaluateThreatBoundaryDoc("tool", outside).missing).toEqual(["Data sources"]);
});
});

describe("read-only signing scan", () => {
it.each([
["const pair = Keypair.fromSecret(input);", "secret_keypair"],
["const pair = Keypair.random();", "secret_keypair"],
["tx.sign(pair);", "sign_call"],
["await freighter.signTransaction(xdr);", "wallet_sign"],
["await server.submitTransaction(tx);", "horizon_submit"],
['rpc("sendTransaction", { transaction })', "rpc_send"]
])("flags %s", (source, id) => {
expect(findSigningCalls({ "lib/tool.ts": source })).toEqual([{ file: "lib/tool.ts", id }]);
});

it("ignores fixtures, tests, e2e specs and request mocks, which build signed samples", () => {
const sources = {
"fixtures/tool.fixture.ts": "tx.sign(source);",
"__tests__/tool.test.ts": "Keypair.random();",
"e2e/tool.spec.ts": "tx.sign(source);",
"msw/handlers.ts": '"sendTransaction"'
};
expect(findSigningCalls(sources)).toEqual([]);
});

it("ignores comments and unrelated calls such as Math.sign and verify", () => {
const sources = {
"lib/tool.ts": [
"// This tool never calls submitTransaction() or tx.sign().",
"/* sendTransaction is deliberately absent */",
"const direction = Math.sign(delta);",
"const valid = keypair.verify(data, signature);",
'const horizon = "https://horizon.stellar.org";'
].join("\n")
};
expect(findSigningCalls(sources)).toEqual([]);
});
});

describe("threat-boundary migration ledger", () => {
const pending = loadMigrationLedger(ledgerPath);

it("only lists slices that exist", () => {
expect([...pending].filter((slug) => !slugs.includes(slug))).toEqual([]);
});

it("does not list a slice that already documents its boundary", () => {
// The ledger must shrink: once a README is complete its slug comes out.
const stale = [...pending].filter((slug) => {
const readme = path.join(featuresDir, slug, "README.md");
let source: string | undefined;
try {
source = readFileSync(readme, "utf8");
} catch {
source = undefined;
}
return evaluateThreatBoundaryDoc(slug, source).ok;
});
expect(stale).toEqual([]);
});

it("documents the representative example, trustline-checker, outside the ledger", async () => {
expect(pending.has("trustline-checker")).toBe(false);
const result = await verifySlice("trustline-checker");
expect(result.threatBoundaryPending).toBe(false);
expect(result.threatBoundaryMissing).toEqual([]);
expect(result.failures).toEqual([]);
});

it("fails a slice that is neither documented nor on the ledger", async () => {
const result = await verifySlice("trustline-limit-planner", { threatBoundaryPending: new Set() });
expect(result.failures.some((failure) => failure.includes("Threat boundary"))).toBe(true);
});

it("finds no signing or submission call in any slice's runtime code", async () => {
const offenders: string[] = [];
for (const slug of slugs) {
const result = await verifySlice(slug);
offenders.push(...result.failures.filter((failure) => failure.includes("read-only boundary")).map((f) => `${slug}: ${f}`));
}
expect(offenders).toEqual([]);
});
});
6 changes: 5 additions & 1 deletion core/audit/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ export const SENSITIVE_ACTIONS = [
/** A freeze window was bypassed for an emergency action. */
"freeze.bypassed",
/** An emergency bypass of a freeze window was refused. */
"freeze.bypass_denied"
"freeze.bypass_denied",
/** The public service mode (degraded, paused, maintenance) was changed or a change was refused. */
"service_mode.changed",
/** A service mode reached its `until` and returned to normal on its own. */
"service_mode.expired"
] as const;

export type AuditAction = (typeof SENSITIVE_ACTIONS)[number];
Expand Down
5 changes: 5 additions & 0 deletions core/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import { AppHeader } from "@/core/layout/AppHeader";
import { Sidebar } from "@/core/layout/Sidebar";
import { NotificationProvider } from "@/core/notifications/NotificationProvider";
import { NetworkProvider } from "@/core/network/NetworkProvider";
import { currentServiceNotice } from "@/core/status/service-config";
import { ServiceStatusBanner } from "@/core/status/ServiceStatusBanner";

export function AppShell({ children }: { children: React.ReactNode }) {
const notice = currentServiceNotice();

return (
<NotificationProvider>
<NetworkProvider>
Expand All @@ -15,6 +19,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</a>
<div className="min-h-screen bg-[linear-gradient(180deg,rgba(255,255,255,0.58),transparent_22rem)]">
<AppHeader />
<ServiceStatusBanner notice={notice} />
<div className="mx-auto flex max-w-7xl border-x border-white/70 bg-white/32 shadow-[0_24px_90px_rgba(80,95,130,0.16)] backdrop-blur-sm">
<Sidebar />
<main id="main-content" className="min-w-0 flex-1 px-4 py-8 sm:px-6 lg:px-8">
Expand Down
8 changes: 8 additions & 0 deletions core/registry/manifests.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { manifest as batchAddressValidator } from "@/features/batch-address-vali
import { manifest as claimableBalances } from "@/features/claimable-balances/manifest";
import { manifest as claimablePredicateBuilder } from "@/features/claimable-predicate-builder/manifest";
import { manifest as contractEvents } from "@/features/contract-events/manifest";
import { manifest as duplicateTransactionScanner } from "@/features/duplicate-transaction-scanner/manifest";
import { manifest as effectsTimeline } from "@/features/effects-timeline/manifest";
import { manifest as explorerLinks } from "@/features/explorer-links/manifest";
import { manifest as federationResolver } from "@/features/federation-resolver/manifest";
Expand All @@ -32,8 +33,10 @@ import { manifest as liquidityPoolCalculator } from "@/features/liquidity-pool-c
import { manifest as liquidityPoolInspector } from "@/features/liquidity-pool-inspector/manifest";
import { manifest as manageDataCodec } from "@/features/manage-data-codec/manifest";
import { manifest as memoInspector } from "@/features/memo-inspector/manifest";
import { manifest as multisigAnalyzer } from "@/features/multisig-analyzer/manifest";
import { manifest as muxedAccountCodec } from "@/features/muxed-account-codec/manifest";
import { manifest as networkComparison } from "@/features/network-comparison/manifest";
import { manifest as offerInspector } from "@/features/offer-inspector/manifest";
import { manifest as operationBrowser } from "@/features/operation-browser/manifest";
import { manifest as operationSourceMap } from "@/features/operation-source-map/manifest";
import { manifest as orderbookViewer } from "@/features/orderbook-viewer/manifest";
Expand All @@ -57,6 +60,7 @@ import { manifest as sorobanDecoder } from "@/features/soroban-decoder/manifest"
import { manifest as sorobanFeeEstimator } from "@/features/soroban-fee-estimator/manifest";
import { manifest as sorobanSpecViewer } from "@/features/soroban-spec-viewer/manifest";
import { manifest as sponsoredReserves } from "@/features/sponsored-reserves/manifest";
import { manifest as sponsorshipPlanner } from "@/features/sponsorship-planner/manifest";
import { manifest as strkeyInspector } from "@/features/strkey-inspector/manifest";
import { manifest as testnetFaucet } from "@/features/testnet-faucet/manifest";
import { manifest as tomlValidator } from "@/features/toml-validator/manifest";
Expand Down Expand Up @@ -88,6 +92,7 @@ export const generatedManifests: FeatureManifest[] = [
claimableBalances,
claimablePredicateBuilder,
contractEvents,
duplicateTransactionScanner,
effectsTimeline,
explorerLinks,
federationResolver,
Expand All @@ -102,8 +107,10 @@ export const generatedManifests: FeatureManifest[] = [
liquidityPoolInspector,
manageDataCodec,
memoInspector,
multisigAnalyzer,
muxedAccountCodec,
networkComparison,
offerInspector,
operationBrowser,
operationSourceMap,
orderbookViewer,
Expand All @@ -127,6 +134,7 @@ export const generatedManifests: FeatureManifest[] = [
sorobanFeeEstimator,
sorobanSpecViewer,
sponsoredReserves,
sponsorshipPlanner,
strkeyInspector,
testnetFaucet,
tomlValidator,
Expand Down
4 changes: 4 additions & 0 deletions core/registry/panels.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const generatedPanels: Record<string, ComponentType> = {
"claimable-balances": () => import("@/features/claimable-balances/panel").then((mod) => mod.default as ComponentType),
"claimable-predicate-builder": () => import("@/features/claimable-predicate-builder/panel").then((mod) => mod.default as ComponentType),
"contract-events": () => import("@/features/contract-events/panel").then((mod) => mod.default as ComponentType),
"duplicate-transaction-scanner": () => import("@/features/duplicate-transaction-scanner/panel").then((mod) => mod.default as ComponentType),
"effects-timeline": () => import("@/features/effects-timeline/panel").then((mod) => mod.default as ComponentType),
"explorer-links": () => import("@/features/explorer-links/panel").then((mod) => mod.default as ComponentType),
"federation-resolver": () => import("@/features/federation-resolver/panel").then((mod) => mod.default as ComponentType),
Expand All @@ -35,8 +36,10 @@ export const generatedPanels: Record<string, ComponentType> = {
"liquidity-pool-inspector": () => import("@/features/liquidity-pool-inspector/panel").then((mod) => mod.default as ComponentType),
"manage-data-codec": () => import("@/features/manage-data-codec/panel").then((mod) => mod.default as ComponentType),
"memo-inspector": () => import("@/features/memo-inspector/panel").then((mod) => mod.default as ComponentType),
"multisig-analyzer": () => import("@/features/multisig-analyzer/panel").then((mod) => mod.default as ComponentType),
"muxed-account-codec": () => import("@/features/muxed-account-codec/panel").then((mod) => mod.default as ComponentType),
"network-comparison": () => import("@/features/network-comparison/panel").then((mod) => mod.default as ComponentType),
"offer-inspector": () => import("@/features/offer-inspector/panel").then((mod) => mod.default as ComponentType),
"operation-browser": () => import("@/features/operation-browser/panel").then((mod) => mod.default as ComponentType),
"operation-source-map": () => import("@/features/operation-source-map/panel").then((mod) => mod.default as ComponentType),
"orderbook-viewer": () => import("@/features/orderbook-viewer/panel").then((mod) => mod.default as ComponentType),
Expand All @@ -60,6 +63,7 @@ export const generatedPanels: Record<string, ComponentType> = {
"soroban-fee-estimator": () => import("@/features/soroban-fee-estimator/panel").then((mod) => mod.default as ComponentType),
"soroban-spec-viewer": () => import("@/features/soroban-spec-viewer/panel").then((mod) => mod.default as ComponentType),
"sponsored-reserves": () => import("@/features/sponsored-reserves/panel").then((mod) => mod.default as ComponentType),
"sponsorship-planner": () => import("@/features/sponsorship-planner/panel").then((mod) => mod.default as ComponentType),
"strkey-inspector": () => import("@/features/strkey-inspector/panel").then((mod) => mod.default as ComponentType),
"testnet-faucet": () => import("@/features/testnet-faucet/panel").then((mod) => mod.default as ComponentType),
"toml-validator": () => import("@/features/toml-validator/panel").then((mod) => mod.default as ComponentType),
Expand Down
Loading