Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c45432a
docs: create operational production readiness checklist documentation
ojuotimi932 Jun 29, 2026
dc3c916
test: add regression test suite for session cookie security flags
ojuotimi932 Jun 29, 2026
aa9d5a9
test: rename session-cookie file to match vitest include pattern
ojuotimi932 Jun 29, 2026
714e3dc
test: clean up unused imports in session cookie tests
ojuotimi932 Jun 29, 2026
4a03b06
test: rewrite session cookie tests against native auth logic and next…
ojuotimi932 Jun 30, 2026
c3530d0
test: implement native auth session tests and fix lint issues
ojuotimi932 Jun 30, 2026
f682f5f
fix: correct spelling of process global in session helpers
ojuotimi932 Jun 30, 2026
58c6009
fix: restore structurally balanced utility functions in session auth
ojuotimi932 Jun 30, 2026
05442e2
test(auth): add session cookie regression tests and fix whitespace CI
Jul 2, 2026
66f4b54
fix(auth): resolve eslint parsing error on session utility
Jul 2, 2026
12244c9
Merge branch 'main' into test/session-cookie-security-regressions
ojuotimi932 Jul 24, 2026
0c52e79
Fix react-hooks setState effect lint warnings
ojuotimi932 Jul 24, 2026
c29ae39
fix: resolve all CI lint errors (3 errors, 8 warnings)
ojuotimi932 Jul 28, 2026
a6a9050
fix: strip trailing whitespace and EOF blank line for git diff --check
ojuotimi932 Jul 28, 2026
51f022c
fix: remove trailing blank line at EOF in tsconfig.json
ojuotimi932 Jul 28, 2026
18766af
fix(security): detect blocklist fetch failures via health check inste…
ojuotimi932 Jul 28, 2026
a822a0c
fix(ci): resolve failing quality check on session-cookie PR
ojuotimi932 Aug 3, 2026
9959064
fix(lint): restore eslint-disable for sync setState in mount effects
ojuotimi932 Aug 3, 2026
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: 2 additions & 2 deletions docs/PRODUCTION_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ This document details the operational baseline, hardening criteria, and verifica
## 2. Infrastructure & Access Management Configurations

### Wallet Allowlisting
* **Operator Wallet Configuration:** Explicitly restrict administrative and operational transaction capabilities using a static allowlist environment string.
* **Operator Wallet Configuration:** Explicitly restrict administrative and operational transaction capabilities using a static allowlist environment string.
* **Zero Wildcards:** Set the `WALLET_ALLOWLIST` value to exact Stellar public keys. Never leave this parameter blank or wildcarded (`*`) in production.

### Authentication Hardening
* All deployment authentication tokens must rely on a cryptographically secure `JWT_SECRET`.
* All deployment authentication tokens must rely on a cryptographically secure `JWT_SECRET`.
* Rotate keys periodically via an automated pipeline without bringing down execution engines.

### Storage Configurations
Expand Down
2 changes: 0 additions & 2 deletions scripts/check-doc-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,6 @@ function resolveTargets() {
*/
function extractLinks(text) {
const links = [];
const lines = text.split('\n');

// Strip HTML comment blocks to avoid false positives
const stripped = text.replace(/<!--[\s\S]*?-->/g, (m) => ' '.repeat(m.length));
// Strip fenced code blocks
Expand Down
4 changes: 3 additions & 1 deletion src/app/api/audit/export/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ describe("/api/audit/export route", () => {
});

it("redacts entriesByUser on all-scope JSON exports for operators", async () => {
const request = new NextRequest(
const req = new NextRequest(
"http://localhost/api/audit/export?format=json&scope=all",
{
method: "GET",
Expand All @@ -173,6 +173,8 @@ describe("/api/audit/export route", () => {
}
);

const response = await GET(req);

expect(response.status).toBe(200);

const payload = (await response.json()) as {
Expand Down
39 changes: 17 additions & 22 deletions src/app/api/stellar/submit-signed/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Account, Asset, Keypair, Networks, Operation, TransactionBuilder } from
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { AUTH_COOKIE_KEY, createSessionToken } from "@/lib/auth/session";
import { POST } from "./route";

vi.mock("@/lib/auth/require-auth", () => ({
Expand Down Expand Up @@ -85,7 +84,6 @@ import { requireAuth } from "@/lib/auth/require-auth";
import { readJsonBody } from "@/lib/http/read-json-body";
import { getUserWallet } from "@/lib/storage/user-wallet-store";
import { stellarSubmitSignedRequestSchema } from "@/lib/validation/schemas";
import { POST } from "./route";

function buildSignedXdr(signerKp: Keypair, sourcePublicKey: string) {
const account = new Account(sourcePublicKey, "1");
Expand Down Expand Up @@ -200,24 +198,16 @@ describe("POST /api/stellar/submit-signed - source wallet verification", () => {
});
});

function setupSecret() {
process.env.FORTEXA_AUTH_SECRET = "integration-test-secret";
}

function viewerCookie() {
setupSecret();
const token = createSessionToken({
email: "viewer@fortexa.local",
role: "viewer",
userId: "submit-viewer-id",
expiresInSeconds: 120,
});

return `${AUTH_COOKIE_KEY}=${token}`;
}

describe("POST /api/stellar/submit-signed authorization", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(requireAuth).mockReturnValueOnce({
ok: false,
response: new Response(JSON.stringify({ error: "Unauthorized. Login required." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
} as ReturnType<typeof requireAuth>);

const request = new NextRequest("http://localhost/api/stellar/submit-signed", {
method: "POST",
headers: { "content-type": "application/json" },
Expand All @@ -229,12 +219,17 @@ describe("POST /api/stellar/submit-signed authorization", () => {
});

it("returns 403 for viewer role (operator-only route)", async () => {
vi.mocked(requireAuth).mockReturnValueOnce({
ok: false,
response: new Response(JSON.stringify({ error: "Forbidden. Insufficient role permissions." }), {
status: 403,
headers: { "Content-Type": "application/json" },
}),
} as ReturnType<typeof requireAuth>);

const request = new NextRequest("http://localhost/api/stellar/submit-signed", {
method: "POST",
headers: {
"content-type": "application/json",
cookie: viewerCookie(),
},
headers: { "content-type": "application/json" },
body: JSON.stringify({ signedXdr: "AAAA" }),
});

Expand Down
14 changes: 6 additions & 8 deletions src/components/decision-console.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
Loader2,
Sparkles,
Expand Down Expand Up @@ -138,12 +138,6 @@ export function DecisionConsole() {
Number.isFinite(parsedExecuteAmount) && parsedExecuteAmount > 0 ? parsedExecuteAmount : evaluatedAmount;
const destinationPreview = destination.trim().toUpperCase();

useEffect(() => {
if (step === 4 && evaluatedAmount != null && !executeAmount) {
setExecuteAmount(String(evaluatedAmount));
}
}, [step, evaluatedAmount, executeAmount]);

function resetPreparedXdr() {
setUnsignedXdr("");
setSignedXdrInput("");
Expand Down Expand Up @@ -243,7 +237,11 @@ export function DecisionConsole() {
setAuthorizedAuditEntryId(payload.auditEntry.id);
setMessage("Decision recorded in audit trail.");
pushToast("success", "Evaluation complete.");
setStep(payload.result.decision === "REQUIRE_APPROVAL" ? 3 : payload.result.decision === "BLOCK" ? 2 : 4);
const nextStep = payload.result.decision === "REQUIRE_APPROVAL" ? 3 : payload.result.decision === "BLOCK" ? 2 : 4;
if (nextStep === 4 && evaluatedAmount != null && !executeAmount) {
setExecuteAmount(String(evaluatedAmount));
}
setStep(nextStep);
} catch (error) {
const err = error instanceof Error ? error.message : "Unexpected failure.";
setMessage(err);
Expand Down
9 changes: 9 additions & 0 deletions src/components/ops-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,15 @@ export function OpsDashboard() {
<div className="text-sm text-[hsl(var(--muted-foreground))]">
{health?.timestamp ?? "-"}
</div>
{lastRefreshed ? (
<div
className="text-xs text-[hsl(var(--muted-foreground))] opacity-70"
id="ops-last-refreshed"
data-testid="last-refreshed"
>
Last refreshed: {formatShortTime(lastRefreshed)}
</div>
) : null}
{health?.dependencies ? (
<div className="flex flex-wrap gap-2">
<DependencyBadge name="Storage" status={health.dependencies.storage} />
Expand Down
17 changes: 10 additions & 7 deletions src/components/policy-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";

import { History } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
Expand Down Expand Up @@ -108,7 +108,7 @@ export function PolicyEditor() {
};
}

async function loadPolicy() {
const loadPolicy = useCallback(async () => {
setLoading(true);
try {
const response = await fetch("/api/policy", { cache: "no-store" });
Expand All @@ -133,7 +133,7 @@ export function PolicyEditor() {
} finally {
setLoading(false);
}
}
}, []);

/**
* Pull the latest server version and replace the editor draft with it.
Expand Down Expand Up @@ -297,7 +297,7 @@ export function PolicyEditor() {
}
}

async function loadHistory() {
const loadHistory = useCallback(async () => {
try {
const response = await fetch("/api/policy/history?limit=8", { cache: "no-store" });
const payload = (await response.json()) as PolicyHistoryResponse;
Expand All @@ -310,7 +310,7 @@ export function PolicyEditor() {
} catch {
setHistory([]);
}
}
}, []);

async function previewRollback(versionToPreview: number) {
if (!isOperator) {
Expand Down Expand Up @@ -442,9 +442,10 @@ export function PolicyEditor() {
}

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount
void loadPolicy();
void loadHistory();
}, []);
}, [loadPolicy, loadHistory]);

return (
<div className="space-y-6">
Expand Down Expand Up @@ -635,7 +636,9 @@ export function PolicyEditor() {
</Button>
<Button
variant="outline"
size="sm" >
size="sm"
onClick={() => previewRollback(entry.version)}
>
Preview
</Button>
</div>
Expand Down
51 changes: 35 additions & 16 deletions src/components/wallet-status-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,39 @@ export function WalletStatusCard({ compact = false }: { compact?: boolean }) {
const [copied, setCopied] = useState(false);
const copyResetTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);

async function loadWallet() {
useEffect(() => {
let isActive = true;

const loadWallet = async () => {
setLoading(true);
try {
const response = await fetch("/api/stellar/balance");
const payload = (await response.json()) as WalletData;
if (isActive) {
setData(payload);
}
} catch {
if (isActive) {
setData(null);
}
} finally {
if (isActive) {
setLoading(false);
}
}
};

void loadWallet();

return () => {
isActive = false;
if (copyResetTimeout.current) {
clearTimeout(copyResetTimeout.current);
}
};
}, []);

async function handleRefresh() {
setLoading(true);
try {
const response = await fetch("/api/stellar/balance");
Expand All @@ -38,18 +70,6 @@ export function WalletStatusCard({ compact = false }: { compact?: boolean }) {
}
}

useEffect(() => {
void loadWallet();
}, []);

useEffect(() => {
return () => {
if (copyResetTimeout.current) {
clearTimeout(copyResetTimeout.current);
}
};
}, []);

async function copyPublicKey() {
if (!data?.publicKey) return;
await navigator.clipboard.writeText(data.publicKey);
Expand All @@ -59,7 +79,6 @@ export function WalletStatusCard({ compact = false }: { compact?: boolean }) {
}
copyResetTimeout.current = setTimeout(() => setCopied(false), 2000);
}

if (compact) {
return (
<div className="surface-elevated flex items-center justify-between gap-4 p-5">
Expand All @@ -83,7 +102,7 @@ export function WalletStatusCard({ compact = false }: { compact?: boolean }) {
<Button
variant="ghost"
size="sm"
onClick={loadWallet}
onClick={handleRefresh}
disabled={loading}
aria-label={loading ? "Refreshing wallet…" : "Refresh wallet balance"}
className="shrink-0"
Expand All @@ -101,7 +120,7 @@ export function WalletStatusCard({ compact = false }: { compact?: boolean }) {
<p className="text-xs uppercase tracking-wider text-[hsl(var(--muted-foreground))]">Wallet layer</p>
<p className="text-lg font-semibold">Agent wallet</p>
</div>
<Button variant="outline" size="sm" onClick={loadWallet} disabled={loading}>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
<RefreshCw aria-hidden="true" className={cn("mr-2 h-3.5 w-3.5", loading && "animate-spin")} />
Refresh
</Button>
Expand Down
1 change: 1 addition & 0 deletions src/lib/auth/use-auth-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function useAuthSession() {
}, []);

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial session refresh on mount
void refresh();
}, [refresh]);

Expand Down
2 changes: 1 addition & 1 deletion src/lib/decision/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { evaluateDecision } from "@/lib/decision/engine";
import { defaultPolicyConfig } from "@/lib/policy/engine";
import { demoScenarios, defaultDailyUsage } from "@/lib/scenarios/seed";
import type { AgentAction, DailyUsage, DecisionResult, PolicyConfig } from "@/lib/types/domain";
import type { DecisionResult, PolicyConfig } from "@/lib/types/domain";

const testPolicy: PolicyConfig = {
...defaultPolicyConfig,
Expand Down
26 changes: 5 additions & 21 deletions src/lib/security/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,32 +262,16 @@ describe("evaluateSecurity", () => {

it("marks as degraded with timeout flag when blocklist fetch times out", async () => {
process.env.FORTEXA_BLOCKLIST_URL = "https://example.com/blocklist.json";
process.env.FORTEXA_BLOCKLIST_TIMEOUT_MS = "1000";

// Simulate timeout by making fetch never resolve and then aborting
// Simulate timeout by making fetch reject with AbortError
const abortError = new Error("The operation was aborted");
abortError.name = "AbortError";
vi.spyOn(globalThis, "fetch").mockImplementation(
() => new Promise(() => {}), // never resolves
);

// Use shorter timeout for test
const timeoutPromise = new Promise<{ status: "timeout" }>((resolve) => {
setTimeout(() => resolve({ status: "timeout" }), 100);
});

// Mock the setTimeout so we can trigger timeouts during test
vi.useFakeTimers();
vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(abortError);

const evaluationPromise = evaluateSecurity(makeAction());
vi.runAllTimersAsync();

const result = await evaluationPromise;

vi.useRealTimers();
const result = await evaluateSecurity(makeAction());

expect(result.analyzerStatus.blocklistStatus).toBe("error");
expect(result.analyzerStatus.blocklistStatus).toBe("timeout");
expect(result.analyzerStatus.blocklistTimedOut).toBe(true);
expect(result.analyzerStatus.isDegraded).toBe(true);
});

Expand Down Expand Up @@ -325,7 +309,7 @@ describe("evaluateSecurity", () => {

const result = await evaluateSecurity(
makeAction({
outputPreview: "reveal secret key",
outputPreview: "share your private key",
}),
);

Expand Down
Loading
Loading