Skip to content

Commit a647196

Browse files
author
Lasim
committed
feat(gateway): implement automatic device registration during login
1 parent 4ace49e commit a647196

File tree

4 files changed

+307
-3
lines changed

4 files changed

+307
-3
lines changed

services/gateway/src/commands/login.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DeployStackAPI } from '../core/auth/api-client';
77
import { MCPConfigService } from '../core/mcp';
88
import { AuthenticationError } from '../types/auth';
99
import { ServerStartService } from '../services/server-start-service';
10+
import { detectDeviceInfo } from '../utils/device-detection';
1011

1112
export function registerLoginCommand(program: Command) {
1213
program
@@ -43,10 +44,33 @@ export function registerLoginCommand(program: Command) {
4344
timeout: 120000 // 2 minutes
4445
});
4546

46-
spinner = ora('Storing credentials securely...').start();
47+
spinner = ora('Registering device...').start();
4748

48-
// Store credentials
49-
await storage.storeCredentials(authResult.credentials);
49+
// NEW: Automatic device registration
50+
try {
51+
const api = new DeployStackAPI(authResult.credentials, options.url);
52+
const deviceInfo = await detectDeviceInfo();
53+
const device = await api.registerOrUpdateDevice(deviceInfo);
54+
55+
spinner.text = 'Storing credentials securely...';
56+
57+
// Store credentials with device context
58+
await storage.storeCredentials({
59+
...authResult.credentials,
60+
deviceId: device.id
61+
});
62+
63+
console.log(chalk.green(`📱 Device registered: ${device.device_name}`));
64+
} catch (deviceError) {
65+
// If device registration fails, continue without it
66+
spinner.text = 'Storing credentials securely...';
67+
await storage.storeCredentials(authResult.credentials);
68+
69+
console.log(chalk.yellow('⚠️ Device registration failed - continuing without device context'));
70+
if (deviceError instanceof Error) {
71+
console.log(chalk.gray(` Device Error: ${deviceError.message}`));
72+
}
73+
}
5074

5175
// Set default team as selected team and download MCP config
5276
spinner.text = 'Setting up default team...';

services/gateway/src/core/auth/api-client.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fetch from 'node-fetch';
33
import { StoredCredentials, UserInfo, TokenInfo, Team, AuthError, AuthenticationError } from '../../types/auth';
44
import { MCPInstallationsResponse } from '../../types/mcp';
55
import { buildAuthConfig } from '../../utils/auth-config';
6+
import { Device, DeviceInfo } from '../../utils/device-detection';
67

78
export class DeployStackAPI {
89
private credentials: StoredCredentials;
@@ -98,6 +99,162 @@ export class DeployStackAPI {
9899
return response;
99100
}
100101

102+
/**
103+
* Get device by hardware ID
104+
* @param hardwareId Hardware fingerprint
105+
* @returns Device if found, null otherwise
106+
*/
107+
async getDeviceByHardwareId(hardwareId: string): Promise<Device | null> {
108+
try {
109+
const endpoint = `${this.baseUrl}/api/users/me/devices`;
110+
const response = await this.makeRequest(endpoint);
111+
112+
if (response.success && response.devices) {
113+
const device = response.devices.find((d: Device) => d.hardware_id === hardwareId);
114+
return device || null;
115+
}
116+
117+
return null;
118+
} catch {
119+
// If we get a 404 or other error, assume no device found
120+
return null;
121+
}
122+
}
123+
124+
/**
125+
* Create a new device
126+
* @param deviceInfo Device information
127+
* @returns Created device
128+
*/
129+
async createDevice(deviceInfo: DeviceInfo): Promise<Device> {
130+
const endpoint = `${this.baseUrl}/api/users/me/devices`;
131+
const deviceData = {
132+
device_name: deviceInfo.hostname, // Default to hostname
133+
hostname: deviceInfo.hostname,
134+
hardware_id: deviceInfo.hardware_id,
135+
os_type: deviceInfo.os_type,
136+
os_version: deviceInfo.os_version,
137+
arch: deviceInfo.arch,
138+
node_version: deviceInfo.node_version,
139+
user_agent: deviceInfo.user_agent,
140+
last_login_at: new Date().toISOString(),
141+
last_activity_at: new Date().toISOString()
142+
};
143+
144+
const response = await this.makeRequest(endpoint, {
145+
method: 'POST',
146+
body: JSON.stringify(deviceData)
147+
});
148+
149+
if (response.success && response.device) {
150+
return response.device;
151+
}
152+
153+
throw new AuthenticationError(
154+
AuthError.NETWORK_ERROR,
155+
'Failed to create device'
156+
);
157+
}
158+
159+
/**
160+
* Update an existing device
161+
* @param deviceId Device ID
162+
* @param updates Device updates
163+
* @returns Updated device
164+
*/
165+
async updateDevice(deviceId: string, updates: { device_name?: string }): Promise<Device> {
166+
const endpoint = `${this.baseUrl}/api/users/me/devices/${deviceId}`;
167+
168+
// The backend only accepts device_name updates via PUT
169+
const updateData = {
170+
device_name: updates.device_name || 'Updated Device'
171+
};
172+
173+
const response = await this.makeRequest(endpoint, {
174+
method: 'PUT',
175+
body: JSON.stringify(updateData)
176+
});
177+
178+
if (response.success && response.device) {
179+
return response.device;
180+
}
181+
182+
throw new AuthenticationError(
183+
AuthError.NETWORK_ERROR,
184+
'Failed to update device'
185+
);
186+
}
187+
/**
188+
* Update device activity (internal method)
189+
* This would be called during login to update last_login_at
190+
* For now, we'll just update the device name to trigger an update
191+
* @param deviceId Device ID
192+
* @returns Updated device
193+
*/
194+
async updateDeviceActivity(deviceId: string): Promise<Device> {
195+
// Since the backend only supports device_name updates,
196+
// we'll just update with the current name to trigger last update timestamp
197+
const endpoint = `${this.baseUrl}/api/users/me/devices/${deviceId}`;
198+
199+
// Get current device first to preserve the name
200+
const currentDevice = await this.getDeviceById(deviceId);
201+
202+
const updateData = {
203+
device_name: currentDevice.device_name
204+
};
205+
206+
const response = await this.makeRequest(endpoint, {
207+
method: 'PUT',
208+
body: JSON.stringify(updateData)
209+
});
210+
211+
if (response.success && response.device) {
212+
return response.device;
213+
}
214+
215+
throw new AuthenticationError(
216+
AuthError.NETWORK_ERROR,
217+
'Failed to update device activity'
218+
);
219+
}
220+
221+
/**
222+
* Get device by ID
223+
* @param deviceId Device ID
224+
* @returns Device
225+
*/
226+
async getDeviceById(deviceId: string): Promise<Device> {
227+
const endpoint = `${this.baseUrl}/api/users/me/devices/${deviceId}`;
228+
const response = await this.makeRequest(endpoint);
229+
230+
if (response.success && response.device) {
231+
return response.device;
232+
}
233+
234+
throw new AuthenticationError(
235+
AuthError.NETWORK_ERROR,
236+
'Device not found'
237+
);
238+
}
239+
240+
/**
241+
* Register or update a device (convenience method)
242+
* @param deviceInfo Device information
243+
* @returns Device (created or updated)
244+
*/
245+
async registerOrUpdateDevice(deviceInfo: DeviceInfo): Promise<Device> {
246+
// First, try to find existing device by hardware ID
247+
const existingDevice = await this.getDeviceByHardwareId(deviceInfo.hardware_id);
248+
249+
if (existingDevice) {
250+
// Update existing device activity (this will update the timestamp)
251+
return await this.updateDeviceActivity(existingDevice.id);
252+
} else {
253+
// Create new device
254+
return await this.createDevice(deviceInfo);
255+
}
256+
}
257+
101258
/**
102259
* Make an authenticated API request
103260
* @param endpoint API endpoint URL

services/gateway/src/types/auth.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export interface StoredCredentials {
1313
id: string;
1414
name: string;
1515
};
16+
// Device information
17+
deviceId?: string;
1618
}
1719

1820
export interface UserInfo {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import os from 'os';
2+
import crypto from 'crypto';
3+
4+
export interface DeviceInfo {
5+
hostname: string;
6+
os_type: string;
7+
os_version: string;
8+
arch: string;
9+
node_version: string;
10+
hardware_id: string;
11+
user_agent: string;
12+
}
13+
14+
export interface Device {
15+
id: string;
16+
user_id: string;
17+
device_name: string;
18+
hostname: string | null;
19+
hardware_id: string | null;
20+
os_type: string | null;
21+
os_version: string | null;
22+
arch: string | null;
23+
node_version: string | null;
24+
last_ip: string | null;
25+
user_agent: string | null;
26+
is_active: boolean;
27+
is_trusted: boolean;
28+
last_login_at: string | null;
29+
last_activity_at: string | null;
30+
created_at: string;
31+
updated_at: string;
32+
}
33+
34+
/**
35+
* Get platform name in a user-friendly format
36+
*/
37+
function getPlatformName(platform: string): string {
38+
switch (platform) {
39+
case 'darwin':
40+
return 'macOS';
41+
case 'win32':
42+
return 'Windows';
43+
case 'linux':
44+
return 'Linux';
45+
default:
46+
return platform;
47+
}
48+
}
49+
50+
/**
51+
* Generate a stable hardware fingerprint for device identification
52+
*/
53+
export async function generateHardwareFingerprint(): Promise<string> {
54+
try {
55+
// Get network interfaces for MAC addresses
56+
const networkInterfaces = os.networkInterfaces();
57+
const macAddresses = Object.values(networkInterfaces)
58+
.flat()
59+
.filter(iface => !iface?.internal && iface?.mac !== '00:00:00:00:00:00')
60+
.map(iface => iface?.mac)
61+
.filter(Boolean)
62+
.sort();
63+
64+
// Get CPU information
65+
const cpus = os.cpus();
66+
const cpuModel = cpus.length > 0 ? cpus[0].model : 'unknown';
67+
68+
// Create fingerprint data
69+
const fingerprintData = {
70+
macs: macAddresses,
71+
hostname: os.hostname(),
72+
platform: os.platform(),
73+
arch: os.arch(),
74+
cpuModel: cpuModel,
75+
// Add some entropy from system info
76+
totalmem: os.totalmem(),
77+
homedir: os.homedir()
78+
};
79+
80+
// Generate SHA256 hash
81+
const fingerprint = crypto
82+
.createHash('sha256')
83+
.update(JSON.stringify(fingerprintData))
84+
.digest('hex');
85+
86+
// Return first 32 characters for consistency
87+
return fingerprint.substring(0, 32);
88+
} catch {
89+
// Fallback fingerprint if hardware detection fails
90+
const fallbackData = {
91+
hostname: os.hostname(),
92+
platform: os.platform(),
93+
arch: os.arch(),
94+
timestamp: Date.now()
95+
};
96+
97+
const fallbackFingerprint = crypto
98+
.createHash('sha256')
99+
.update(JSON.stringify(fallbackData))
100+
.digest('hex');
101+
102+
return fallbackFingerprint.substring(0, 32);
103+
}
104+
}
105+
106+
/**
107+
* Detect current device information
108+
*/
109+
export async function detectDeviceInfo(): Promise<DeviceInfo> {
110+
const packageVersion = process.env.npm_package_version || '1.0.0';
111+
112+
return {
113+
hostname: os.hostname(),
114+
os_type: getPlatformName(os.platform()),
115+
os_version: os.release(),
116+
arch: os.arch(),
117+
node_version: process.version,
118+
hardware_id: await generateHardwareFingerprint(),
119+
user_agent: `DeployStack-CLI/${packageVersion} (${os.platform()}; ${os.arch()})`
120+
};
121+
}

0 commit comments

Comments
 (0)