Skip to content
Open
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
11 changes: 8 additions & 3 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,19 @@ NEXT_PUBLIC_DATA_API_BASE_URL=https://api.monarchlend.xyz
NEXT_PUBLIC_MONARCH_API_NEW=https://indexer.monarchlend.xyz/graphql

# Set in Vercel Preview only. The app sends it only on *.vercel.app hosts.
# Do not use the old NEXT_PUBLIC_MONARCH_API_KEY variable.
NEXT_PUBLIC_MONARCH_PREVIEW_API_KEY=

# Server-only token used by /api/api-keys to create user API keys through the data gateway admin endpoint.
# Server-only credential used only by /api/api-keys to create user-facing mk_live/mk_test API keys.
# This is not the data-api internal write key.
MONARCH_API_KEYS_ADMIN_TOKEN=
# Optional override. Defaults to the direct Cloudflare Worker admin endpoint.
# Server-only admin endpoint for API-key generation. Set the real value in deployment secrets.
MONARCH_API_KEYS_ADMIN_URL=

# Server-only data-api internal write access used by referral routes.
# Set real values only in deployment secrets or local untracked env files.
DATA_API_INTERNAL_ORIGIN=
DATA_API_INTERNAL_ADMIN_KEY=

# ==================== Oracle Metadata ====================
# Base URL for oracle metadata Gist (without trailing slash)
# Example: https://gist.githubusercontent.com/username/gist-id/raw
Expand Down
287 changes: 45 additions & 242 deletions app/api/api-keys/route.ts
Original file line number Diff line number Diff line change
@@ -1,221 +1,98 @@
import { type NextRequest, NextResponse } from 'next/server';
import { createPublicClient, getAddress, http, isAddress, type Address, type Chain } from 'viem';
import { verifyMessage } from 'viem/actions';
import { arbitrum, base, etherlink, hyperEvm, mainnet, monad, optimism, polygon, unichain } from 'viem/chains';
import { parseApiKeyRequestMessage } from '@/utils/apiKeyRequest';
import { SupportedNetworks, isSupportedNetwork } from '@/utils/supported-networks';

const DEFAULT_ADMIN_ENDPOINT = 'https://data-api-gateway-worker.antonassocareer.workers.dev/admin/api-keys';
const REQUEST_TTL_MS = 10 * 60 * 1000;
const REQUEST_CLOCK_SKEW_MS = 60 * 1000;
const ADMIN_REQUEST_TIMEOUT_MS = 10_000;
const VERCEL_PREVIEW_HOST_SUFFIX = '.vercel.app';
const FIRST_PARTY_HOSTS = new Set(['monarchlend.xyz', 'www.monarchlend.xyz']);
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);

const VERIFICATION_CHAINS: Record<SupportedNetworks, Chain> = {
[SupportedNetworks.Mainnet]: mainnet,
[SupportedNetworks.Optimism]: optimism,
[SupportedNetworks.Base]: base,
[SupportedNetworks.Polygon]: polygon,
[SupportedNetworks.Unichain]: unichain,
[SupportedNetworks.Arbitrum]: arbitrum,
[SupportedNetworks.Etherlink]: etherlink,
[SupportedNetworks.HyperEVM]: hyperEvm,
[SupportedNetworks.Monad]: monad,
};

const RPC_ENV_BY_CHAIN: Partial<Record<SupportedNetworks, string | undefined>> = {
[SupportedNetworks.Mainnet]: process.env.NEXT_PUBLIC_ETHEREUM_RPC,
[SupportedNetworks.Optimism]: process.env.NEXT_PUBLIC_OPTIMISM_RPC,
[SupportedNetworks.Base]: process.env.NEXT_PUBLIC_BASE_RPC,
[SupportedNetworks.Polygon]: process.env.NEXT_PUBLIC_POLYGON_RPC,
[SupportedNetworks.Unichain]: process.env.NEXT_PUBLIC_UNICHAIN_RPC,
[SupportedNetworks.Arbitrum]: process.env.NEXT_PUBLIC_ARBITRUM_RPC,
[SupportedNetworks.Etherlink]: process.env.NEXT_PUBLIC_ETHERLINK_RPC,
[SupportedNetworks.HyperEVM]: process.env.NEXT_PUBLIC_HYPEREVM_RPC,
[SupportedNetworks.Monad]: process.env.NEXT_PUBLIC_MONAD_RPC,
};

type CreateApiKeyRequestBody = {
address?: unknown;
signature?: unknown;
message?: unknown;
name?: unknown;
};
import { verifySignedWallet } from '@/utils/serverWalletSignature';
import { WALLET_SIGNATURE_CHAIN_ID } from '@/utils/walletSignature';

interface CreateApiKeyBody {
address?: string;
signature?: string;
timestamp?: number;
name?: string;
}

type AdminCreateApiKeyResponse = {
interface AdminCreateApiKeyResponse {
apiKey?: unknown;
key?: unknown;
error?: unknown;
};
}

const API_KEY_ADMIN_TIMEOUT_MS = 10_000;

export async function POST(request: NextRequest) {
const adminToken = process.env.MONARCH_API_KEYS_ADMIN_TOKEN?.trim();
if (!adminToken) {
const adminUrl = process.env.MONARCH_API_KEYS_ADMIN_URL?.trim();
if (!adminToken || !adminUrl) {
return NextResponse.json({ error: 'API key creation is not configured.' }, { status: 500 });
}

const body = await readCreateApiKeyRequest(request);
if ('error' in body) return NextResponse.json({ error: body.error }, { status: 400 });

const parsedMessage = parseApiKeyRequestMessage(body.message);
if (!parsedMessage) {
return NextResponse.json({ error: 'Invalid signature message.' }, { status: 400 });
}

if (!isAddress(body.address) || !isAddress(parsedMessage.wallet)) {
return NextResponse.json({ error: 'Invalid wallet address.' }, { status: 400 });
}

const address = getAddress(body.address);
if (getAddress(parsedMessage.wallet) !== address) {
return NextResponse.json({ error: 'Signed wallet does not match connected wallet.' }, { status: 400 });
}

const applicationOrigin = getApplicationOrigin(request);
if (!applicationOrigin) {
return NextResponse.json({ error: 'Unsupported application origin.' }, { status: 403 });
}

if (parsedMessage.origin !== applicationOrigin) {
return NextResponse.json({ error: 'Signed origin does not match request origin.' }, { status: 400 });
}

if (!isFreshTimestamp(parsedMessage.issuedAt)) {
return NextResponse.json({ error: 'Signature request expired.' }, { status: 400 });
const body = (await request.json().catch(() => null)) as CreateApiKeyBody | null;
if (!body || typeof body.address !== 'string' || typeof body.signature !== 'string' || typeof body.timestamp !== 'number') {
return NextResponse.json({ error: 'address, signature, and timestamp are required.' }, { status: 400 });
}

if (!/^[A-Za-z0-9-]{16,80}$/.test(parsedMessage.nonce)) {
return NextResponse.json({ error: 'Invalid signature nonce.' }, { status: 400 });
}

if (!isSupportedNetwork(parsedMessage.chainId)) {
return NextResponse.json({ error: 'Unsupported signature chain.' }, { status: 400 });
}

let signatureValid: boolean;
try {
signatureValid = await verifyWalletSignature({
address,
chainId: parsedMessage.chainId,
message: body.message,
signature: body.signature,
});
} catch {
return NextResponse.json({ error: 'Failed to verify wallet signature.' }, { status: 502 });
}

if (!signatureValid) {
const address = await verifySignedWallet({
address: body.address,
signature: body.signature,
timestamp: body.timestamp,
purpose: 'API key',
});
if (!address) {
return NextResponse.json({ error: 'Invalid wallet signature.' }, { status: 401 });
}

const adminResponse = await createGatewayApiKey({
return createGatewayApiKey({
adminUrl,
adminToken,
address,
name: body.name,
chainId: parsedMessage.chainId,
origin: parsedMessage.origin,
issuedAt: parsedMessage.issuedAt,
nonce: parsedMessage.nonce,
name: typeof body.name === 'string' ? body.name : '',
signedAt: body.timestamp,
});

return adminResponse;
}

async function readCreateApiKeyRequest(request: NextRequest): Promise<
| {
address: string;
signature: string;
message: string;
name: string;
}
| { error: string }
> {
let body: CreateApiKeyRequestBody;
try {
body = (await request.json()) as CreateApiKeyRequestBody;
if (!isRecord(body)) {
return { error: 'Invalid JSON body.' };
}
} catch {
return { error: 'Invalid JSON body.' };
}

const address = readRequiredString(body.address);
const signature = readRequiredString(body.signature);
const message = readRequiredString(body.message);
const name = sanitizeKeyName(body.name);

if (!address || !signature || !message) {
return { error: 'address, signature, and message are required.' };
}

if (!/^0x(?:[0-9a-fA-F]{2})+$/.test(signature)) {
return { error: 'Invalid signature format.' };
}

return {
address,
signature,
message,
name,
};
}

async function createGatewayApiKey({
adminUrl,
adminToken,
address,
name,
chainId,
origin,
issuedAt,
nonce,
signedAt,
}: {
adminUrl: string;
adminToken: string;
address: string;
name: string;
chainId: number;
origin: string;
issuedAt: string;
nonce: string;
signedAt: number;
}) {
const adminEndpoint = process.env.MONARCH_API_KEYS_ADMIN_URL?.trim() || DEFAULT_ADMIN_ENDPOINT;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ADMIN_REQUEST_TIMEOUT_MS);
let response: Response;
let body: AdminCreateApiKeyResponse;

try {
response = await fetch(adminEndpoint, {
response = await fetch(adminUrl, {
method: 'POST',
signal: AbortSignal.timeout(API_KEY_ADMIN_TIMEOUT_MS),
headers: {
Authorization: `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name,
name: name.trim().replace(/\s+/g, ' ').slice(0, 120) || 'Monarch API key',
environment: 'live',
scopes: ['data.read', 'indexer.query'],
tier: 'free',
rateLimitTier: 'free',
metadata: {
ownerAddress: address,
chainId,
origin,
signedAt: issuedAt,
requestNonce: nonce,
signatureChainId: WALLET_SIGNATURE_CHAIN_ID,
signedAt,
createdBy: 'monarch-api-key-console',
},
}),
signal: controller.signal,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
body = (await response.json().catch(() => ({}))) as AdminCreateApiKeyResponse;
} catch (error) {
const status = error instanceof Error && error.name === 'AbortError' ? 504 : 502;
return NextResponse.json({ error: status === 504 ? 'API gateway timed out.' : 'Failed to connect to the API gateway.' }, { status });
} finally {
clearTimeout(timeout);
} catch (caught) {
if (caught instanceof DOMException && caught.name === 'TimeoutError') {
return NextResponse.json({ error: 'API gateway timed out.' }, { status: 504 });
}

return NextResponse.json({ error: 'Failed to connect to the API gateway.' }, { status: 502 });
}

if (!response.ok) {
Expand All @@ -229,79 +106,5 @@ async function createGatewayApiKey({
return NextResponse.json({ error: 'Gateway did not return an API key.' }, { status: 502 });
}

return NextResponse.json(
{
apiKey: body.apiKey,
key: body.key,
},
{ status: 201 },
);
}

function verifyWalletSignature({
address,
chainId,
message,
signature,
}: {
address: string;
chainId: SupportedNetworks;
message: string;
signature: string;
}) {
const rpcUrl = RPC_ENV_BY_CHAIN[chainId]?.trim() || undefined;
const client = createPublicClient({
chain: VERIFICATION_CHAINS[chainId],
transport: http(rpcUrl),
});

return verifyMessage(client, {
address: address as Address,
message,
signature: signature as `0x${string}`,
});
}

function getApplicationOrigin(request: NextRequest): string | null {
const host = readForwardedHeader(request.headers.get('x-forwarded-host')) ?? request.headers.get('host');
if (!host) return null;

if (!isAllowedApplicationHost(host)) return null;

const protocol = readForwardedHeader(request.headers.get('x-forwarded-proto')) ?? new URL(request.url).protocol.replace(/:$/, '');
return `${protocol}://${host}`.replace(/\/+$/, '');
}

function readForwardedHeader(value: string | null): string | null {
return value?.split(',')[0]?.trim() || null;
}

function isAllowedApplicationHost(host: string): boolean {
const hostname = host.toLowerCase().replace(/:\d+$/, '');
return FIRST_PARTY_HOSTS.has(hostname) || hostname.endsWith(VERCEL_PREVIEW_HOST_SUFFIX) || LOOPBACK_HOSTS.has(hostname);
}

function isFreshTimestamp(value: string): boolean {
const issuedAtMs = Date.parse(value);
if (!Number.isFinite(issuedAtMs)) return false;

const now = Date.now();
return issuedAtMs <= now + REQUEST_CLOCK_SKEW_MS && now - issuedAtMs <= REQUEST_TTL_MS;
}

function readRequiredString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}

function sanitizeKeyName(value: unknown): string {
if (typeof value !== 'string') return 'Monarch API key';

const trimmed = value.trim().replace(/\s+/g, ' ');
if (!trimmed) return 'Monarch API key';

return trimmed.slice(0, 120);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
return NextResponse.json({ apiKey: body.apiKey, key: body.key }, { status: 201 });
}
Loading