refactor(hub): enforce conversation-scoped device authorization
This commit is contained in:
parent
3123506657
commit
a0bb88e7b7
21 changed files with 528 additions and 99 deletions
30
apps/desktop/src/main/electron-env.d.ts
vendored
30
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -70,18 +70,20 @@ interface SkillInfo {
|
|||
triggers: string[]
|
||||
}
|
||||
|
||||
interface DeviceMeta {
|
||||
userAgent?: string
|
||||
platform?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
interface DeviceEntryInfo {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
addedAt: number
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
interface DeviceMeta {
|
||||
userAgent?: string
|
||||
platform?: string
|
||||
language?: string
|
||||
clientName?: string
|
||||
}
|
||||
|
||||
interface DeviceEntryInfo {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationIds: string[]
|
||||
addedAt: number
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
||||
interface SkillAddResult {
|
||||
ok: boolean
|
||||
|
|
@ -188,8 +190,8 @@ interface ElectronAPI {
|
|||
closeAgent: (id: string) => Promise<unknown>
|
||||
closeConversation: (id: string) => Promise<unknown>
|
||||
sendMessage: (agentId: string, content: string, conversationId?: string) => Promise<unknown>
|
||||
registerToken: (token: string, agentId: string, expiresAt: number) => Promise<unknown>
|
||||
onDeviceConfirmRequest: (callback: (deviceId: string, meta?: DeviceMeta) => void) => void
|
||||
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) => Promise<unknown>
|
||||
onDeviceConfirmRequest: (callback: (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => void) => void
|
||||
offDeviceConfirmRequest: () => void
|
||||
deviceConfirmResponse: (deviceId: string, allowed: boolean) => void
|
||||
listDevices: () => Promise<DeviceEntryInfo[]>
|
||||
|
|
|
|||
|
|
@ -501,11 +501,14 @@ export function registerHubIpcHandlers(): void {
|
|||
* Register a one-time token for device verification.
|
||||
* Called by the QR code component when a token is generated or refreshed.
|
||||
*/
|
||||
ipcMain.handle('hub:registerToken', async (_event, token: string, agentId: string, expiresAt: number) => {
|
||||
ipcMain.handle(
|
||||
'hub:registerToken',
|
||||
async (_event, token: string, agentId: string, conversationId: string, expiresAt: number) => {
|
||||
const h = getHub()
|
||||
h.registerToken(token, agentId, expiresAt)
|
||||
h.registerToken(token, agentId, conversationId, expiresAt)
|
||||
return { ok: true }
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* List all verified (whitelisted) devices.
|
||||
|
|
@ -551,7 +554,7 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi
|
|||
})
|
||||
|
||||
// Register confirm handler on Hub — sends request to renderer, awaits response
|
||||
h.setConfirmHandler((deviceId: string, _agentId: string, meta) => {
|
||||
h.setConfirmHandler((deviceId: string, agentId: string, conversationId: string, meta) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
// Auto-reject if user doesn't respond within 60 seconds
|
||||
const timeout = setTimeout(() => {
|
||||
|
|
@ -566,7 +569,7 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi
|
|||
mainWindow.webContents.send('hub:devices-changed')
|
||||
}
|
||||
})
|
||||
mainWindow.webContents.send('hub:device-confirm-request', deviceId, meta)
|
||||
mainWindow.webContents.send('hub:device-confirm-request', deviceId, agentId, conversationId, meta)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -170,11 +170,27 @@ const electronAPI = {
|
|||
closeConversation: (id: string) => ipcRenderer.invoke('hub:closeConversation', id),
|
||||
sendMessage: (agentId: string, content: string, conversationId?: string) =>
|
||||
ipcRenderer.invoke('hub:sendMessage', agentId, content, conversationId),
|
||||
registerToken: (token: string, agentId: string, expiresAt: number) =>
|
||||
ipcRenderer.invoke('hub:registerToken', token, agentId, expiresAt),
|
||||
onDeviceConfirmRequest: (callback: (deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => void) => {
|
||||
ipcRenderer.on('hub:device-confirm-request', (_event, deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => callback(deviceId, meta))
|
||||
},
|
||||
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) =>
|
||||
ipcRenderer.invoke('hub:registerToken', token, agentId, conversationId, expiresAt),
|
||||
onDeviceConfirmRequest: (
|
||||
callback: (
|
||||
deviceId: string,
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string },
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
'hub:device-confirm-request',
|
||||
(
|
||||
_event,
|
||||
deviceId: string,
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string },
|
||||
) => callback(deviceId, agentId, conversationId, meta),
|
||||
)
|
||||
},
|
||||
offDeviceConfirmRequest: () => {
|
||||
ipcRenderer.removeAllListeners('hub:device-confirm-request')
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ interface DeviceMeta {
|
|||
|
||||
interface PendingConfirm {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationId: string
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
||||
|
|
@ -32,9 +34,11 @@ export function DeviceConfirmDialog() {
|
|||
const [pending, setPending] = useState<PendingConfirm | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => {
|
||||
setPending({ deviceId, meta })
|
||||
})
|
||||
window.electronAPI?.hub.onDeviceConfirmRequest(
|
||||
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => {
|
||||
setPending({ deviceId, agentId, conversationId, meta })
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
window.electronAPI?.hub.offDeviceConfirmRequest()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@ export function ConnectionQRCode({
|
|||
expirySeconds = 30,
|
||||
size = 200,
|
||||
}: ConnectionQRCodeProps) {
|
||||
const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds)
|
||||
const resolvedConversationId = conversationId ?? agentId
|
||||
const { token, expiresAt, refresh } = useQRToken(agentId, resolvedConversationId, expirySeconds)
|
||||
const remaining = useCountdown(expiresAt, refresh)
|
||||
|
||||
// Derive QR data and URL from current token (computed during render)
|
||||
|
|
@ -141,11 +142,11 @@ export function ConnectionQRCode({
|
|||
gateway,
|
||||
hubId,
|
||||
agentId,
|
||||
conversationId: conversationId ?? agentId,
|
||||
conversationId: resolvedConversationId,
|
||||
token,
|
||||
expires: expiresAt,
|
||||
}),
|
||||
[gateway, hubId, agentId, conversationId, token, expiresAt]
|
||||
[gateway, hubId, agentId, resolvedConversationId, token, expiresAt]
|
||||
)
|
||||
|
||||
const connectionUrl = useMemo(() => {
|
||||
|
|
@ -153,12 +154,12 @@ export function ConnectionQRCode({
|
|||
gateway,
|
||||
hub: hubId,
|
||||
agent: agentId,
|
||||
conversation: conversationId ?? agentId,
|
||||
conversation: resolvedConversationId,
|
||||
token,
|
||||
exp: expiresAt.toString(),
|
||||
})
|
||||
return `multica://connect?${params.toString()}`
|
||||
}, [gateway, hubId, agentId, conversationId, token, expiresAt])
|
||||
}, [gateway, hubId, agentId, resolvedConversationId, token, expiresAt])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ function generateToken(): string {
|
|||
* - Auto-refreshes when expired
|
||||
* - Registers token with Hub
|
||||
*/
|
||||
export function useQRToken(agentId: string, expirySeconds: number) {
|
||||
export function useQRToken(agentId: string, conversationId: string, expirySeconds: number) {
|
||||
const [token, setToken] = useState(generateToken)
|
||||
const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000)
|
||||
|
||||
|
|
@ -20,12 +20,12 @@ export function useQRToken(agentId: string, expirySeconds: number) {
|
|||
const newExpiry = Date.now() + expirySeconds * 1000
|
||||
setToken(newToken)
|
||||
setExpiresAt(newExpiry)
|
||||
window.electronAPI?.hub.registerToken(newToken, agentId, newExpiry)
|
||||
}, [agentId, expirySeconds])
|
||||
window.electronAPI?.hub.registerToken(newToken, agentId, conversationId, newExpiry)
|
||||
}, [agentId, conversationId, expirySeconds])
|
||||
|
||||
// Register initial token
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.registerToken(token, agentId, expiresAt)
|
||||
window.electronAPI?.hub.registerToken(token, agentId, conversationId, expiresAt)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { token, expiresAt, refresh }
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ export function TelegramConnectQR({
|
|||
expirySeconds = 30,
|
||||
size = 200,
|
||||
}: TelegramConnectQRProps) {
|
||||
const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds)
|
||||
const resolvedConversationId = conversationId ?? agentId
|
||||
const { token, expiresAt, refresh } = useQRToken(agentId, resolvedConversationId, expirySeconds)
|
||||
const remaining = useCountdown(expiresAt, refresh)
|
||||
|
||||
const [deepLink, setDeepLink] = useState<string | null>(null)
|
||||
|
|
@ -50,7 +51,7 @@ export function TelegramConnectQR({
|
|||
gateway,
|
||||
hubId,
|
||||
agentId,
|
||||
conversationId: conversationId ?? agentId,
|
||||
conversationId: resolvedConversationId,
|
||||
token,
|
||||
expires: expiresAt,
|
||||
}),
|
||||
|
|
@ -81,7 +82,7 @@ export function TelegramConnectQR({
|
|||
|
||||
fetchCode()
|
||||
return () => { cancelled = true }
|
||||
}, [token, expiresAt, gateway, hubId, agentId, conversationId])
|
||||
}, [token, expiresAt, gateway, hubId, agentId, resolvedConversationId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface DeviceMeta {
|
|||
export interface DeviceEntry {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationIds: string[]
|
||||
addedAt: number
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ interface DeviceMeta {
|
|||
|
||||
interface PendingConfirm {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationId: string
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
||||
|
|
@ -42,9 +44,11 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
|
|||
|
||||
// Listen for device confirm requests during onboarding
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => {
|
||||
setPending({ deviceId, meta })
|
||||
})
|
||||
window.electronAPI?.hub.onDeviceConfirmRequest(
|
||||
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => {
|
||||
setPending({ deviceId, agentId, conversationId, meta })
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
window.electronAPI?.hub.offDeviceConfirmRequest()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue