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
20 changes: 17 additions & 3 deletions apps/daemon/src/integrations/vela-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ interface VelaWalletBalanceResponse {
updatedAt?: unknown;
}

function isValidUsdBalance(value: unknown): value is string {
return typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value);
}

function publicUser(user: VelaUser | null): AmrWalletSnapshot['user'] {
if (!user) return null;
return {
Expand Down Expand Up @@ -164,16 +168,26 @@ export function createVelaWalletSnapshotReader(options: VelaWalletReaderOptions
});
}
const body = (await response.json()) as VelaWalletBalanceResponse;
const balanceUsd = typeof body.balanceUsd === 'string' ? body.balanceUsd : null;
if (balanceUsd === null) {
if (!isValidUsdBalance(body.balanceUsd)) {
const cached = cache.get(key);
if (cached) {
return {
...withCacheSource(cached.snapshot, true),
error: {
code: 'upstream',
message: 'AMR wallet balance response contained an invalid balanceUsd.',
},
};
}
return unavailableSnapshot({
code: 'upstream',
fetchedAt,
message: 'AMR wallet balance response was missing balanceUsd.',
message: 'AMR wallet balance response contained an invalid balanceUsd.',
profile: input.profile,
user: input.user,
});
}
const balanceUsd = body.balanceUsd;
const snapshot: AmrWalletSnapshot = {
status: 'available',
profile: input.profile,
Expand Down
63 changes: 57 additions & 6 deletions apps/daemon/src/routes/vela.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ const AMR_API_PROXY_PREFIX = '/api/integrations/vela/api-proxy';
const VELA_MESSAGE_CENTER_PREFIX = '/api/integrations/vela/message-center';
const VELA_PUBLIC_MESSAGE_CENTER_PREFIX = '/api/integrations/vela/message-center-public';
const AMR_API_UPSTREAM_ORIGIN = 'https://amr-api.open-design.ai';
const PROXY_HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'proxy-connection',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
const VELA_WORKSPACE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;

/**
* Upper bound, in ms, on how long a cold-cache `/status` read waits for the
Expand Down Expand Up @@ -198,6 +210,21 @@ function shouldStreamVelaProxyRequest(req: Request, body: Buffer | null): boolea
return req.method !== 'GET' && req.method !== 'HEAD' && body == null;
}

function connectionHeaderTokens(value: string | string[] | undefined): Set<string> {
const values = Array.isArray(value) ? value : value === undefined ? [] : [value];
return new Set(
values
.flatMap((entry) => entry.split(','))
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean),
);
}

function isProxyHopByHopHeader(name: string, connectionTokens: Set<string>): boolean {
const lower = name.toLowerCase();
return PROXY_HOP_BY_HOP_HEADERS.has(lower) || connectionTokens.has(lower);
}

/**
* Pipe one leg of the AMR proxy with an explicit source-error guard.
*
Expand Down Expand Up @@ -226,16 +253,29 @@ function proxyAmrApiRequest(req: Request, res: Response): void {
return;
}
const target = new URL(suffix, AMR_API_UPSTREAM_ORIGIN);
if (!target.pathname.startsWith('/api/v1/')) {
res.status(404).json({ error: 'unknown_amr_api_proxy_path' });
return;
}
const workspaceId = req.headers['x-vela-workspace-id'];
if (
workspaceId !== undefined
&& (Array.isArray(workspaceId) || !VELA_WORKSPACE_ID_PATTERN.test(workspaceId))
) {
res.status(400).json({ error: 'invalid_workspace_id' });
return;
}
const requestConnectionTokens = connectionHeaderTokens(req.headers.connection);
if (workspaceId !== undefined && requestConnectionTokens.has('x-vela-workspace-id')) {
res.status(400).json({ error: 'invalid_workspace_id' });
return;
}
const body = velaProxyRequestBody(req);
const streamBody = shouldStreamVelaProxyRequest(req, body);
const headers: Record<string, string | string[]> = {};
for (const [key, value] of Object.entries(req.headers)) {
const lower = key.toLowerCase();
if (
lower === 'host' ||
lower === 'connection' ||
lower === 'transfer-encoding'
) {
if (lower === 'host' || isProxyHopByHopHeader(lower, requestConnectionTokens)) {
Comment thread
lefarcen marked this conversation as resolved.
continue;
}
if (lower === 'content-length' && body) continue;
Expand All @@ -254,8 +294,14 @@ function proxyAmrApiRequest(req: Request, res: Response): void {
},
(upstreamRes) => {
res.status(upstreamRes.statusCode ?? 502);
const responseConnectionTokens = connectionHeaderTokens(upstreamRes.headers.connection);
for (const [key, value] of Object.entries(upstreamRes.headers)) {
if (value !== undefined) res.setHeader(key, value);
if (
value !== undefined
&& !isProxyHopByHopHeader(key, responseConnectionTokens)
) {
res.setHeader(key, value);
}
}
pipeProxyStreamWithGuard(upstreamRes, res, (err) => {
if (!res.headersSent) {
Expand All @@ -274,6 +320,11 @@ function proxyAmrApiRequest(req: Request, res: Response): void {
res.end();
}
});
const abortUpstream = () => {
if (!res.writableEnded && !upstream.destroyed) upstream.destroy();
};
req.once('aborted', abortUpstream);
res.once('close', abortUpstream);
if (body) upstream.write(body);
if (streamBody) {
pipeProxyStreamWithGuard(req, upstream, () => upstream.destroy());
Expand Down
108 changes: 108 additions & 0 deletions apps/daemon/tests/integrations/vela-wallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { createVelaWalletSnapshotReader } from '../../src/integrations/vela-wallet.js';

let originalHome: string | undefined;
let originalProfile: string | undefined;
let testHome: string;

function seedWalletLogin(): void {
const configFile = path.join(testHome, '.amr', 'config.json');
mkdirSync(path.dirname(configFile), { recursive: true });
writeFileSync(
configFile,
JSON.stringify({
profiles: {
local: {
apiUrl: 'https://wallet.example.test',
controlKey: 'ck-wallet-unit',
runtimeKey: 'rt-wallet-unit',
user: {
id: 'wallet-unit-user',
email: 'wallet-unit@example.com',
plan: 'plus',
},
},
},
}),
'utf8',
);
}

beforeEach(() => {
originalHome = process.env.HOME;
originalProfile = process.env.OPEN_DESIGN_AMR_PROFILE;
testHome = mkdtempSync(path.join(tmpdir(), 'od-vela-wallet-'));
process.env.HOME = testHome;
process.env.OPEN_DESIGN_AMR_PROFILE = 'local';
seedWalletLogin();
});

afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalProfile === undefined) delete process.env.OPEN_DESIGN_AMR_PROFILE;
else process.env.OPEN_DESIGN_AMR_PROFILE = originalProfile;
rmSync(testHome, { recursive: true, force: true });
});

describe('createVelaWalletSnapshotReader balance validation', () => {
it.each([
{ label: 'missing', balanceUsd: undefined },
{ label: 'numeric', balanceUsd: 20 },
{ label: 'negative', balanceUsd: '-1.00' },
{ label: 'NaN', balanceUsd: 'NaN' },
{ label: 'infinite', balanceUsd: 'Infinity' },
{ label: 'exponent', balanceUsd: '1e2' },
])('rejects a $label balance when there is no valid cached snapshot', async ({ balanceUsd }) => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify(balanceUsd === undefined ? {} : { balanceUsd }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const reader = createVelaWalletSnapshotReader({ fetch: fetchMock as typeof fetch });

await expect(reader.read()).resolves.toMatchObject({
status: 'unavailable',
balanceUsd: null,
source: 'unavailable',
stale: false,
error: { code: 'upstream' },
});
});

it('serves the last valid snapshot as stale when a refresh returns an invalid balance', async () => {
let responseBody: unknown = { balanceUsd: '20.00' };
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify(responseBody), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const reader = createVelaWalletSnapshotReader({
fetch: fetchMock as typeof fetch,
ttlMs: 60_000,
});

await expect(reader.read()).resolves.toMatchObject({
status: 'available',
balanceUsd: '20.00',
source: 'vela_api',
stale: false,
});
responseBody = { balanceUsd: '-1.00' };

await expect(reader.read({ refresh: true })).resolves.toMatchObject({
status: 'available',
balanceUsd: '20.00',
source: 'daemon_cache',
stale: true,
error: { code: 'upstream' },
});
});
});
Loading
Loading