diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index 437418d2e..e498384df 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -155,6 +155,10 @@ function getLayer(filePath: string): LayerName | null { // openplc-web parity explicit (this folder is byte-identical // between repos). if (rel.startsWith('middleware/shared/utils/')) return 'utils' + // Shared runtime-auth (RuntimeTokenManager) is pure, dependency-free logic + // reachable from adapters/backend/main on both platforms — same `utils` rule + // set, byte-identical between repos. + if (rel.startsWith('middleware/shared/runtime-auth/')) return 'utils' if (rel.match(/^middleware\/adapters\/[^/]+\/components\//)) return 'adapter-components' if (rel.startsWith('middleware/adapters/')) return 'adapters' diff --git a/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts b/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts index 590b5a0a7..1de587f46 100644 --- a/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts +++ b/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts @@ -149,9 +149,9 @@ describe('createEditorCompilerPlatformPort', () => { cleanBuild: false, mainProcessBridge: { makeRuntimeApiRequest: jest.fn(), + makeRuntimeApiUpload: jest.fn(), }, compressSourceFolder: jest.fn(), - sendRuntimeUpload: jest.fn(), pollTimeoutMs: 1000, pollIntervalMs: 10, startTimeoutMs: 1000, @@ -321,7 +321,7 @@ describe('createEditorCompilerPlatformPort', () => { })) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest'] const port = createEditorCompilerPlatformPort( makeHandlers(), - makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }), ) const result = await port.checkRuntimeVersion( { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, @@ -338,7 +338,7 @@ describe('createEditorCompilerPlatformPort', () => { const log = jest.fn() const port = createEditorCompilerPlatformPort( makeHandlers(), - makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }), ) const result = await port.checkRuntimeVersion( { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, @@ -355,7 +355,7 @@ describe('createEditorCompilerPlatformPort', () => { const log = jest.fn() const port = createEditorCompilerPlatformPort( makeHandlers(), - makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }), ) const result = await port.checkRuntimeVersion( { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index fe5d59831..c8b914fc9 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -50,10 +50,19 @@ import type { KnownPou } from '@root/backend/shared/utils/PLC/split-program-st' type LibraryCompileBridge = { makeRuntimeApiRequest: ( ipAddress: string, - jwtToken: string, endpoint: string, responseParser?: (data: string) => T, ) => Promise<{ success: true; data?: T } | { success: false; error: string }> + // Required to satisfy compileProgram's bridge contract; never invoked on the + // library path (it compiles with runtimeIpAddress=null, so no upload runs). + makeRuntimeApiUpload: (opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }) => Promise<{ success: true; data: string } | { success: false; error: string }> loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] } } @@ -1903,73 +1912,8 @@ class CompilerModule { }) } - /** - * Send a compiled program file to the runtime's `/api/upload-file` - * over HTTPS via a multipart/form-data POST. Pure transport — no - * polling, no PLC start, no UI logging. Used as the `uploadProgram` - * callback fed to the shared `deployRuntimeProgram` orchestrator. - * - * v3 callers pass `program.st` + `text/plain`; v4 callers pass the - * compiled zip + `application/zip`. `cleanBuild` toggles the - * `?clean=1` flag the runtime honours by wiping `build/` and ccache - * before compiling. - */ - private async sendRuntimeUpload(opts: { - hostname: string - jwtToken: string - filename: string - contentType: string - fileBuffer: Buffer - cleanBuild: boolean - onUploadAccepted?: (responseBody: string) => void - }): Promise<{ success: boolean; error?: string }> { - const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2) - const header = Buffer.from( - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="file"; filename="${opts.filename}"\r\n` + - `Content-Type: ${opts.contentType}\r\n\r\n`, - ) - const footer = Buffer.from(`\r\n--${boundary}--\r\n`) - const body = Buffer.concat([header, opts.fileBuffer, footer] as unknown as ReadonlyArray) - - return new Promise<{ success: boolean; error?: string }>((resolve) => { - const req = https.request( - { - hostname: opts.hostname, - port: 8443, - path: opts.cleanBuild ? '/api/upload-file?clean=1' : '/api/upload-file', - method: 'POST', - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': body.length, - Authorization: `Bearer ${opts.jwtToken}`, - }, - ...getRuntimeHttpsOptions(), - } as https.RequestOptions, - (res: IncomingMessage) => { - let data = '' - res.on('data', (chunk: Buffer) => { - data += chunk.toString() - }) - res.on('end', () => { - if (res.statusCode === 200) { - opts.onUploadAccepted?.(data) - resolve({ success: true }) - } else { - resolve({ success: false, error: data || `HTTP ${res.statusCode}` }) - } - }) - }, - ) - req.setTimeout(300_000, () => { - req.destroy() - resolve({ success: false, error: 'Upload request timed out after 5 minutes' }) - }) - req.on('error', (err: Error) => resolve({ success: false, error: err.message })) - req.write(body) - req.end() - }) - } + // Runtime upload moved to MainProcessBridge.makeRuntimeApiUpload so it shares + // the single token authority (transparent refresh + retry on an expired JWT). // !! Deprecated: This method is a outdated implementation and should be removed. async createXmlFile( @@ -2457,10 +2401,17 @@ class CompilerModule { mainProcessBridge: { makeRuntimeApiRequest: ( ipAddress: string, - jwtToken: string, endpoint: string, responseParser?: (data: string) => T, ) => Promise<{ success: true; data?: T } | { success: false; error: string }> + makeRuntimeApiUpload: (opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }) => Promise<{ success: true; data: string } | { success: false; error: string }> /** * Resolve a list of project-enabled library names to parsed * `.stlib` archives. Bundled libraries are always-on and @@ -2710,7 +2661,6 @@ class CompilerModule { cleanBuild: cleanBuild ?? false, mainProcessBridge, compressSourceFolder: (folderPath: string) => this.compressSourceFolder(folderPath), - sendRuntimeUpload: (opts) => this.sendRuntimeUpload(opts), pollTimeoutMs: CompilerModule.COMPILATION_STATUS_TIMEOUT_MS, pollIntervalMs: CompilerModule.COMPILATION_STATUS_POLL_INTERVAL_MS, startTimeoutMs: POST_BUILD_START_TIMEOUT_MS, diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index e5e184d8a..5dc1a2116 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -106,28 +106,26 @@ export interface EditorCompilerPlatformPortContext { mainProcessBridge: { makeRuntimeApiRequest: ( ipAddress: string, - jwtToken: string, endpoint: string, responseParser?: (data: string) => T, ) => Promise<{ success: true; data?: T } | { success: false; error: string }> + /** Upload the runtime-v4 program bundle. Owns token refresh internally + * (via the token authority), so the upload self-heals on expiry like every + * other runtime call. */ + makeRuntimeApiUpload: (opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }) => Promise<{ success: true; data: string } | { success: false; error: string }> } /** Compress the source folder into the runtime v4 upload zip. * Delegated through context so the port adapter doesn't pull * in the `archiver`-dependent compressSourceFolder method (which * has its own private state on CompilerModule). */ compressSourceFolder: (folderPath: string) => Promise - /** Send the upload request to a runtime device. Wraps - * CompilerModule.sendRuntimeUpload with the right multipart - * payload structure. */ - sendRuntimeUpload: (opts: { - hostname: string - jwtToken: string - filename: string - contentType: string - fileBuffer: Buffer - cleanBuild: boolean - onUploadAccepted?: (responseBody: string) => void - }) => Promise<{ success: boolean; error?: string }> /** Timeout for the post-upload compile-status poll. */ pollTimeoutMs: number /** Interval for the post-upload compile-status poll. */ @@ -384,9 +382,8 @@ export function createEditorCompilerPlatformPort( const deployOutcome = await deployRuntimeProgram({ uploadProgram: () => - context.sendRuntimeUpload({ - hostname: deviceContext.ip, - jwtToken: deviceContext.jwt, + context.mainProcessBridge.makeRuntimeApiUpload({ + ipAddress: deviceContext.ip, filename: 'program.zip', contentType: 'application/zip', fileBuffer, @@ -405,7 +402,7 @@ export function createEditorCompilerPlatformPort( status: string logs: string[] exit_code: number | null - }>(deviceContext.ip, deviceContext.jwt, '/api/compilation-status', (data: string) => { + }>(deviceContext.ip, '/api/compilation-status', (data: string) => { return JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null } }) if (!result.success) return { success: false, error: result.error } @@ -414,7 +411,6 @@ export function createEditorCompilerPlatformPort( fetchStartResponse: async () => { const result = await context.mainProcessBridge.makeRuntimeApiRequest( deviceContext.ip, - deviceContext.jwt, '/api/start-plc', (data: string) => { const parsed = JSON.parse(data) as { status?: string } @@ -486,9 +482,8 @@ export function createEditorCompilerPlatformPort( const fileBuffer = Buffer.from(args.programSt, 'utf-8') const deployOutcome = await deployRuntimeProgram({ uploadProgram: () => - context.sendRuntimeUpload({ - hostname: deviceContext.ip, - jwtToken: deviceContext.jwt, + context.mainProcessBridge.makeRuntimeApiUpload({ + ipAddress: deviceContext.ip, filename: 'program.st', contentType: 'text/plain', fileBuffer, @@ -507,7 +502,7 @@ export function createEditorCompilerPlatformPort( status: string logs: string[] exit_code: number | null - }>(deviceContext.ip, deviceContext.jwt, '/api/compilation-status', (data: string) => { + }>(deviceContext.ip, '/api/compilation-status', (data: string) => { return JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null } }) if (!result.success) return { success: false, error: result.error } @@ -516,7 +511,6 @@ export function createEditorCompilerPlatformPort( fetchStartResponse: async () => { const result = await context.mainProcessBridge.makeRuntimeApiRequest( deviceContext.ip, - deviceContext.jwt, '/api/start-plc', (data: string) => { const parsed = JSON.parse(data) as { status?: string } @@ -556,7 +550,6 @@ export function createEditorCompilerPlatformPort( fetchVersion: async () => { const result = await context.mainProcessBridge.makeRuntimeApiRequest<{ version: string }>( deviceContext.ip, - '', // unauthenticated probe '/api/version', (data: string) => JSON.parse(data) as { version: string }, ) diff --git a/src/frontend/hooks/use-runtime-polling.ts b/src/frontend/hooks/use-runtime-polling.ts index 06d93f727..294722135 100644 --- a/src/frontend/hooks/use-runtime-polling.ts +++ b/src/frontend/hooks/use-runtime-polling.ts @@ -171,6 +171,20 @@ export const useRuntimePolling = () => { } }, [runtime, handleConnectionLost, setPlcRuntimeStatus, setTimingStats, setEthercatStatus]) + // Keep the store's connection token in lock-step with the platform's token + // authority. When the authority transparently refreshes an expired token + // (editor main process, or the web adapter's RuntimeTokenManager), it emits + // onTokenRefreshed; adopting it here means every store-reading consumer — the + // compile/upload pipeline, the connection-status UI — uses the live token + // instead of the one captured at login. Without this, an upload kicked off + // after the token aged out would use a stale token and 401. + useEffect(() => { + const unsubscribe = runtime.onTokenRefreshed?.((newToken) => { + useOpenPLCStore.getState().deviceActions.setRuntimeJwtToken(newToken) + }) + return unsubscribe + }, [runtime]) + useEffect(() => { const { workspaceActions } = useOpenPLCStore.getState() diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 2acdb6ee5..4bad31169 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -21,6 +21,7 @@ import type { ListPublicLibrariesArgs, ListPublicLibrariesResponse, } from '@root/middleware/shared/ports/public-catalog-types' +import { createRuntimeTokenManager } from '@root/middleware/shared/runtime-auth/runtime-token-manager' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' import { randomUUID } from 'crypto' @@ -65,8 +66,20 @@ class MainProcessBridge implements MainIpcModule { private debuggerRtuBaudRate: number | null = null private debuggerRtuSlaveId: number | null = null private debuggerJwtToken: string | null = null - private runtimeCredentials: { ipAddress: string; username: string; password: string } | null = null - private tokenRefreshInFlight: Promise<{ success: boolean; accessToken?: string; error?: string }> | null = null + // Address of the runtime this session is authenticated against. Captured at + // login so the token authority can re-authenticate against the same device. + private runtimeIp: string | null = null + // Single token authority for the editor: owns the access token + credentials + // and the refresh/retry-on-401 logic, shared byte-for-byte with the web app. + // Every runtime HTTP call (GET, POST, and the project upload) goes through it, + // so they all self-heal identically when the 15-min JWT expires. + private tokens = createRuntimeTokenManager({ + login: async (credentials) => { + if (!this.runtimeIp) return { success: false, error: 'No runtime address configured' } + const result = await this.performAuthentication(this.runtimeIp, credentials.username, credentials.password) + return { success: result.success, token: result.accessToken, error: result.error } + }, + }) // Current project root path used to validate file-watcher IPC calls private currentProjectPath: string | null = null // File watchers for auto-reload functionality (using watchFile for better macOS compatibility) @@ -103,6 +116,12 @@ class MainProcessBridge implements MainIpcModule { this.pouService = pouService this.compilerModule = compilerModule this.hardwareModule = hardwareModule + + // When the token authority transparently refreshes an expired token, push + // the fresh token to the renderer so its store connection flag tracks it. + this.tokens.onTokenChanged((newToken) => { + this.mainWindow?.webContents?.send('runtime:token-refreshed', newToken) + }) } // ===================== RUNTIME API HANDLERS ===================== @@ -234,29 +253,14 @@ class MainProcessBridge implements MainIpcModule { handleRuntimeLogin = async (_event: IpcMainInvokeEvent, ipAddress: string, username: string, password: string) => { const result = await this.performAuthentication(ipAddress, username, password) if (result.success && result.accessToken) { - this.runtimeCredentials = { ipAddress, username, password } + // Hand the session to the token authority so it can transparently + // re-authenticate against this device when the token expires. + this.runtimeIp = ipAddress + this.tokens.setSession(result.accessToken, { username, password }) } return result } - private async attemptTokenRefresh(): Promise<{ success: boolean; accessToken?: string; error?: string }> { - if (this.tokenRefreshInFlight) { - return this.tokenRefreshInFlight - } - - if (!this.runtimeCredentials) { - return { success: false, error: 'No stored credentials available for token refresh' } - } - - const { ipAddress, username, password } = this.runtimeCredentials - - this.tokenRefreshInFlight = this.performAuthentication(ipAddress, username, password).finally(() => { - this.tokenRefreshInFlight = null - }) - - return this.tokenRefreshInFlight - } - private isTokenExpiredError(statusCode: number | undefined, errorMessage: string): boolean { if (statusCode === 401 || statusCode === 403) { return true @@ -286,50 +290,25 @@ class MainProcessBridge implements MainIpcModule { async makeRuntimeApiRequest( ipAddress: string, - jwtToken: string, endpoint: string, responseParser?: (data: string) => T, ): Promise<{ success: true; data?: T } | { success: false; error: string }> { - try { - const url = this.runtimeUrl(ipAddress, endpoint) - const res = await this.httpRequest({ - method: 'GET', - url, - headers: { Authorization: `Bearer ${jwtToken}` }, - }) - - if (res.statusCode === 200) { - return this.parseApiResponse(res.data, responseParser) - } - - if (!this.isTokenExpiredError(res.statusCode, res.data)) { - return { success: false, error: res.data } - } - - // Attempt token refresh and retry - const refreshResult = await this.attemptTokenRefresh() - if (!refreshResult.success || !refreshResult.accessToken) { - return { - success: false, - error: refreshResult.error ? `Token refresh failed: ${refreshResult.error}` : res.data, + // The token authority owns the live token + refresh. + type Raw = { success: true; data?: T } | { success: false; error: string; statusCode?: number } + const url = this.runtimeUrl(ipAddress, endpoint) + const result = await this.tokens.withAuth( + async (token) => { + try { + const res = await this.httpRequest({ method: 'GET', url, headers: { Authorization: `Bearer ${token}` } }) + if (res.statusCode === 200) return this.parseApiResponse(res.data, responseParser) + return { success: false, error: res.data, statusCode: res.statusCode } + } catch (error) { + return { success: false, error: getErrorMessage(error) } } - } - - this.mainWindow?.webContents?.send('runtime:token-refreshed', refreshResult.accessToken) - - const retryRes = await this.httpRequest({ - method: 'GET', - url, - headers: { Authorization: `Bearer ${refreshResult.accessToken}` }, - }) - - if (retryRes.statusCode === 200) { - return this.parseApiResponse(retryRes.data, responseParser) - } - return { success: false, error: retryRes.data } - } catch (error) { - return { success: false, error: getErrorMessage(error) } - } + }, + (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), + ) + return result.success ? result : { success: false, error: result.error } } /** @@ -348,12 +327,12 @@ class MainProcessBridge implements MainIpcModule { */ makeRuntimeApiPostRequest( ipAddress: string, - jwtToken: string, endpoint: string, body: string, responseParser: (data: string) => T, timeoutMs?: number, ): Promise<{ success: true; data: T } | { success: false; error: string }> { + // Token + refresh owned by the authority. type PostResult = { success: true; data: T } | { success: false; error: string; statusCode?: number } const doRequest = (token: string): Promise => { @@ -410,29 +389,90 @@ class MainProcessBridge implements MainIpcModule { const stripStatus = (r: PostResult): { success: true; data: T } | { success: false; error: string } => r.success ? r : { success: false, error: r.error } - return doRequest(jwtToken).then((result) => { - const statusCode = !result.success ? result.statusCode : undefined - if (!result.success && this.isTokenExpiredError(statusCode, result.error)) { - return this.attemptTokenRefresh().then((refreshResult) => { - if (refreshResult.success && refreshResult.accessToken) { - if (this.mainWindow && this.mainWindow.webContents) { - this.mainWindow.webContents.send('runtime:token-refreshed', refreshResult.accessToken) - } - return doRequest(refreshResult.accessToken).then(stripStatus) - } - return { success: false as const, error: `Token refresh failed: ${refreshResult.error || 'Unknown error'}` } + return this.tokens + .withAuth( + (token) => doRequest(token), + (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), + ) + .then(stripStatus) + } + + /** + * Upload a compiled program (multipart) to the runtime, going through the + * token authority so an expired token is transparently refreshed and the + * upload retried — the same self-healing every other runtime call gets. This + * is the path that previously had no refresh, so a long session's upload 401'd + * while status polling kept working. + */ + makeRuntimeApiUpload(opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }): Promise<{ success: true; data: string } | { success: false; error: string }> { + type UploadResult = { success: true; data: string } | { success: false; error: string; statusCode?: number } + const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2) + const header = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${opts.filename}"\r\n` + + `Content-Type: ${opts.contentType}\r\n\r\n`, + ) + const footer = Buffer.from(`\r\n--${boundary}--\r\n`) + const reqBody = Buffer.concat([header, opts.fileBuffer, footer] as unknown as ReadonlyArray) + const path = opts.cleanBuild ? '/api/upload-file?clean=1' : '/api/upload-file' + + const doRequest = (token: string): Promise => + new Promise((resolve) => { + const req = https.request( + { + hostname: opts.ipAddress, + port: this.RUNTIME_API_PORT, + path, + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': reqBody.length, + Authorization: `Bearer ${token}`, + }, + ...getRuntimeHttpsOptions(), + } as https.RequestOptions, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode === 200) resolve({ success: true, data }) + else resolve({ success: false, error: data || `HTTP ${res.statusCode}`, statusCode: res.statusCode }) + }) + }, + ) + req.setTimeout(300_000, () => { + req.destroy() + resolve({ success: false, error: 'Upload request timed out after 5 minutes' }) }) - } - return stripStatus(result) - }) + req.on('error', (err: Error) => resolve({ success: false, error: err.message })) + req.write(reqBody) + req.end() + }) + + return this.tokens + .withAuth( + (token) => doRequest(token), + (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), + ) + .then((result) => { + if (result.success) { + opts.onUploadAccepted?.(result.data) + return { success: true as const, data: result.data } + } + return { success: false as const, error: result.error } + }) } - handleRuntimeGetStatus = async ( - _event: IpcMainInvokeEvent, - ipAddress: string, - jwtToken: string, - includeStats?: boolean, - ) => { + handleRuntimeGetStatus = async (_event: IpcMainInvokeEvent, ipAddress: string, includeStats?: boolean) => { try { // Build the endpoint path with optional include_stats query parameter const endpoint = includeStats ? '/api/status?include_stats=true' : '/api/status' @@ -468,7 +508,7 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiRequest<{ status: string timing_stats?: TimingStatsResponse - }>(ipAddress, jwtToken, endpoint, (data: string) => { + }>(ipAddress, endpoint, (data: string) => { const response = JSON.parse(data) as { status: string timing_stats?: TimingStatsResponse @@ -505,7 +545,7 @@ class MainProcessBridge implements MainIpcModule { } } - handleRuntimeStartPlc = async (_event: IpcMainInvokeEvent, ipAddress: string, jwtToken: string) => { + handleRuntimeStartPlc = async (_event: IpcMainInvokeEvent, ipAddress: string) => { try { // Parse the body so the renderer can drive a retry-on-BUSY // loop around `COMMAND:BUSY` replies (the runtime answers BUSY @@ -513,7 +553,6 @@ class MainProcessBridge implements MainIpcModule { // upload). See `backend/shared/library/start-plc-after-build.ts`. const result = await this.makeRuntimeApiRequest<{ status?: string }>( ipAddress, - jwtToken, '/api/start-plc', (data: string) => JSON.parse(data) as { status?: string }, ) @@ -525,19 +564,18 @@ class MainProcessBridge implements MainIpcModule { } } - handleRuntimeStopPlc = async (_event: IpcMainInvokeEvent, ipAddress: string, jwtToken: string) => { + handleRuntimeStopPlc = async (_event: IpcMainInvokeEvent, ipAddress: string) => { try { - return await this.makeRuntimeApiRequest(ipAddress, jwtToken, '/api/stop-plc') + return await this.makeRuntimeApiRequest(ipAddress, '/api/stop-plc') } catch (error) { return { success: false, error: getErrorMessage(error) } } } - handleRuntimeGetCompilationStatus = async (_event: IpcMainInvokeEvent, ipAddress: string, jwtToken: string) => { + handleRuntimeGetCompilationStatus = async (_event: IpcMainInvokeEvent, ipAddress: string) => { try { const result = await this.makeRuntimeApiRequest<{ status: string; logs: string[]; exit_code: number | null }>( ipAddress, - jwtToken, '/api/compilation-status', (data: string) => { const response = JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null } @@ -550,12 +588,11 @@ class MainProcessBridge implements MainIpcModule { } } - handleRuntimeGetLogs = async (_event: IpcMainInvokeEvent, ipAddress: string, jwtToken: string, minId?: number) => { + handleRuntimeGetLogs = async (_event: IpcMainInvokeEvent, ipAddress: string, minId?: number) => { try { const endpoint = minId !== undefined ? `/api/runtime-logs?id=${minId}` : '/api/runtime-logs' const result = await this.makeRuntimeApiRequest( ipAddress, - jwtToken, endpoint, (data: string) => { const response = JSON.parse(data) as { 'runtime-logs': string | RuntimeLogEntry[] } @@ -573,7 +610,8 @@ class MainProcessBridge implements MainIpcModule { } handleRuntimeClearCredentials = (_event: IpcMainInvokeEvent) => { - this.runtimeCredentials = null + this.tokens.clear() + this.runtimeIp = null return { success: true } } @@ -718,12 +756,10 @@ class MainProcessBridge implements MainIpcModule { handleRuntimeGetSerialPorts = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; ports?: Array<{ device: string; description?: string }>; error?: string }> => { try { const result = await this.makeRuntimeApiRequest<{ ports: Array<{ device: string; description?: string }> }>( ipAddress, - jwtToken, '/api/serial-ports', (data: string) => { const response = JSON.parse(data) as { @@ -1950,12 +1986,10 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATGetInterfaces = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: NetworkInterface[]; error?: string }> => { try { const result = await this.makeRuntimeApiRequest<{ interfaces: NetworkInterface[] }>( ipAddress, - jwtToken, '/api/discovery/interfaces', (data: string) => { const response = JSON.parse(data) as { status: string; interfaces: NetworkInterface[] } @@ -1975,12 +2009,10 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATGetStatus = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: EtherCATServiceStatusResponse; error?: string }> => { try { const result = await this.makeRuntimeApiRequest( ipAddress, - jwtToken, '/api/discovery/ethercat/status', (data: string) => { const parsed = JSON.parse(data) as unknown @@ -2008,7 +2040,6 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATScan = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, scanRequest: EtherCATScanRequest, ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> => { try { @@ -2021,7 +2052,6 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiPostRequest( ipAddress, - jwtToken, '/api/plugin-command', postData, (data: string) => { @@ -2050,7 +2080,6 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATTest = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, testRequest: EtherCATTestRequest, ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> => { try { @@ -2059,7 +2088,6 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiPostRequest( ipAddress, - jwtToken, '/api/discovery/ethercat/test', postData, (data: string) => JSON.parse(data) as EtherCATTestResponse, @@ -2078,7 +2106,6 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATValidate = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, validateRequest: EtherCATValidateRequest, ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => { try { @@ -2086,7 +2113,6 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiPostRequest( ipAddress, - jwtToken, '/api/discovery/ethercat/validate', postData, (data: string) => JSON.parse(data) as EtherCATValidateResponse, @@ -2104,7 +2130,6 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATGetRuntimeStatus = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> => { try { const postData = JSON.stringify({ @@ -2114,7 +2139,6 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiPostRequest( ipAddress, - jwtToken, '/api/plugin-command', postData, (data: string) => { diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 8fe73479f..395d24d3e 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -455,7 +455,6 @@ const rendererProcessBridge = { ipcRenderer.invoke('runtime:login', ipAddress, username, password), runtimeGetStatus: ( ipAddress: string, - jwtToken: string, includeStats?: boolean, ): Promise<{ success: boolean @@ -477,34 +476,28 @@ const rendererProcessBridge = { }> } error?: string - }> => ipcRenderer.invoke('runtime:get-status', ipAddress, jwtToken, includeStats), - runtimeStartPlc: ( - ipAddress: string, - jwtToken: string, - ): Promise<{ success: boolean; error?: string; status?: string }> => - ipcRenderer.invoke('runtime:start-plc', ipAddress, jwtToken), - runtimeStopPlc: (ipAddress: string, jwtToken: string): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('runtime:stop-plc', ipAddress, jwtToken), + }> => ipcRenderer.invoke('runtime:get-status', ipAddress, includeStats), + runtimeStartPlc: (ipAddress: string): Promise<{ success: boolean; error?: string; status?: string }> => + ipcRenderer.invoke('runtime:start-plc', ipAddress), + runtimeStopPlc: (ipAddress: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('runtime:stop-plc', ipAddress), runtimeGetCompilationStatus: ( ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean data?: { status: string; logs: string[]; exit_code: number | null } error?: string - }> => ipcRenderer.invoke('runtime:get-compilation-status', ipAddress, jwtToken), + }> => ipcRenderer.invoke('runtime:get-compilation-status', ipAddress), runtimeGetLogs: ( ipAddress: string, - jwtToken: string, minId?: number, ): Promise<{ success: boolean; logs?: string | RuntimeLogEntry[]; error?: string }> => - ipcRenderer.invoke('runtime:get-logs', ipAddress, jwtToken, minId), + ipcRenderer.invoke('runtime:get-logs', ipAddress, minId), runtimeClearCredentials: (): Promise<{ success: boolean }> => ipcRenderer.invoke('runtime:clear-credentials'), runtimeGetSerialPorts: ( ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; ports?: Array<{ device: string; description?: string }>; error?: string }> => - ipcRenderer.invoke('runtime:get-serial-ports', ipAddress, jwtToken), + ipcRenderer.invoke('runtime:get-serial-ports', ipAddress), runtimeDiscoverDevices: (opts?: { durationMs?: number }): Promise<{ success: boolean; devices?: DiscoveredRuntimeDevice[]; error?: string }> => @@ -521,42 +514,36 @@ const rendererProcessBridge = { // ===================== ETHERCAT DISCOVERY METHODS ===================== etherCATGetInterfaces: ( ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: NetworkInterface[]; error?: string }> => - ipcRenderer.invoke('ethercat:get-interfaces', ipAddress, jwtToken), + ipcRenderer.invoke('ethercat:get-interfaces', ipAddress), etherCATGetStatus: ( ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: EtherCATServiceStatusResponse; error?: string }> => - ipcRenderer.invoke('ethercat:get-status', ipAddress, jwtToken), + ipcRenderer.invoke('ethercat:get-status', ipAddress), etherCATScan: ( ipAddress: string, - jwtToken: string, scanRequest: EtherCATScanRequest, ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> => - ipcRenderer.invoke('ethercat:scan', ipAddress, jwtToken, scanRequest), + ipcRenderer.invoke('ethercat:scan', ipAddress, scanRequest), etherCATTest: ( ipAddress: string, - jwtToken: string, testRequest: EtherCATTestRequest, ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> => - ipcRenderer.invoke('ethercat:test', ipAddress, jwtToken, testRequest), + ipcRenderer.invoke('ethercat:test', ipAddress, testRequest), etherCATValidate: ( ipAddress: string, - jwtToken: string, validateRequest: EtherCATValidateRequest, ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => - ipcRenderer.invoke('ethercat:validate', ipAddress, jwtToken, validateRequest), + ipcRenderer.invoke('ethercat:validate', ipAddress, validateRequest), etherCATGetRuntimeStatus: ( ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> => - ipcRenderer.invoke('ethercat:get-runtime-status', ipAddress, jwtToken), + ipcRenderer.invoke('ethercat:get-runtime-status', ipAddress), // ===================== ESI REPOSITORY METHODS ===================== esiLoadRepositoryIndex: ( diff --git a/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts b/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts index 9079e525a..6090ac0eb 100644 --- a/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts @@ -24,6 +24,12 @@ beforeEach(() => { onRuntimeTokenRefreshed: jest.fn().mockImplementation(() => jest.fn()), runtimeDiscoverDevices: jest.fn().mockResolvedValue({ success: true, devices: [] }), onRuntimeDeviceDiscovered: jest.fn().mockImplementation(() => jest.fn()), + etherCATGetInterfaces: jest.fn().mockResolvedValue({ success: true, data: [] }), + etherCATGetStatus: jest.fn().mockResolvedValue({ success: true, data: {} }), + etherCATScan: jest.fn().mockResolvedValue({ success: true, data: {} }), + etherCATTest: jest.fn().mockResolvedValue({ success: true, data: {} }), + etherCATValidate: jest.fn().mockResolvedValue({ success: true, data: {} }), + etherCATGetRuntimeStatus: jest.fn().mockResolvedValue({ success: true, data: {} }), } as unknown as typeof window.bridge adapter = createEditorRuntimeAdapter(() => mockIpAddress) @@ -41,20 +47,19 @@ describe('login', () => { expect(result).toEqual({ success: true, accessToken: 'jwt-token-123' }) }) - it('stores JWT token internally on success', async () => { + it('marks the session active on success (token lives in main)', async () => { await adapter.login({ username: 'admin', password: 'secret' }) - - // Subsequent calls should use the stored token + // The renderer no longer passes a token; main owns it. Login flips the + // session-active flag the debugger readiness check relies on. + expect(adapter.isReadyForDebug!()).toBe(true) await adapter.getStatus() - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123', undefined) + expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', undefined) }) - it('does not store token on failure', async () => { + it('does not mark the session active on failure', async () => { ;(window.bridge.runtimeLogin as jest.Mock).mockResolvedValue({ success: false, error: 'Bad password' }) await adapter.login({ username: 'admin', password: 'wrong' }) - - await adapter.getStatus() - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', '', undefined) + expect(adapter.isReadyForDebug!()).toBe(false) }) it('returns error when no IP configured', async () => { @@ -136,14 +141,14 @@ describe('getStatus', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.getStatus(true) - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123', true) + expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', true) expect(result).toEqual({ success: true, status: 'RUNNING' }) }) it('passes undefined for includeStats when omitted', async () => { await adapter.getStatus() - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', '', undefined) + expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', undefined) }) it('returns error when no IP configured', async () => { @@ -170,7 +175,7 @@ describe('startPlc', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.startPlc() - expect(window.bridge.runtimeStartPlc).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123') + expect(window.bridge.runtimeStartPlc).toHaveBeenCalledWith('192.168.1.100') expect(result).toEqual({ success: true }) }) @@ -198,7 +203,7 @@ describe('stopPlc', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.stopPlc() - expect(window.bridge.runtimeStopPlc).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123') + expect(window.bridge.runtimeStopPlc).toHaveBeenCalledWith('192.168.1.100') expect(result).toEqual({ success: true }) }) @@ -226,14 +231,14 @@ describe('getLogs', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.getLogs(42) - expect(window.bridge.runtimeGetLogs).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123', 42) + expect(window.bridge.runtimeGetLogs).toHaveBeenCalledWith('192.168.1.100', 42) expect(result).toEqual({ success: true, logs: [] }) }) it('passes undefined minId when omitted', async () => { await adapter.getLogs() - expect(window.bridge.runtimeGetLogs).toHaveBeenCalledWith('192.168.1.100', '', undefined) + expect(window.bridge.runtimeGetLogs).toHaveBeenCalledWith('192.168.1.100', undefined) }) it('returns error when no IP configured', async () => { @@ -263,7 +268,7 @@ describe('getSerialPorts', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.getSerialPorts() - expect(window.bridge.runtimeGetSerialPorts).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123') + expect(window.bridge.runtimeGetSerialPorts).toHaveBeenCalledWith('192.168.1.100') expect(result).toEqual({ success: true, ports }) }) @@ -291,7 +296,7 @@ describe('getCompilationStatus', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.getCompilationStatus() - expect(window.bridge.runtimeGetCompilationStatus).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123') + expect(window.bridge.runtimeGetCompilationStatus).toHaveBeenCalledWith('192.168.1.100') expect(result).toEqual({ success: true, data: { status: 'SUCCESS', logs: [], exit_code: 0 } }) }) @@ -324,7 +329,7 @@ describe('clearCredentials', () => { // Subsequent calls should use empty token await adapter.getStatus() - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', '', undefined) + expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', undefined) }) }) @@ -344,7 +349,9 @@ describe('onTokenRefreshed', () => { expect(unsub).toBe(unsubscribe) }) - it('updates internal JWT when token is refreshed', async () => { + it('forwards the refreshed token from main to the callback', () => { + // The token lives in the main process now; the adapter only relays the + // refresh notification so the renderer store flag can track it. let bridgeHandler: ((_event: unknown, newToken: string) => void) | null = null ;(window.bridge.onRuntimeTokenRefreshed as jest.Mock).mockImplementation( (handler: (_event: unknown, newToken: string) => void) => { @@ -356,14 +363,9 @@ describe('onTokenRefreshed', () => { const callback = jest.fn() adapter.onTokenRefreshed!(callback) - // Simulate token refresh from main process bridgeHandler!({}, 'new-token-456') expect(callback).toHaveBeenCalledWith('new-token-456') - - // Subsequent calls should use the refreshed token - await adapter.getStatus() - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', 'new-token-456', undefined) }) }) @@ -436,6 +438,67 @@ describe('onDeviceDiscovered', () => { }) }) +// --------------------------------------------------------------------------- +// EtherCAT discovery methods — thin bridge delegators (no token; main owns it) +// --------------------------------------------------------------------------- + +describe('EtherCAT discovery methods', () => { + const cases: Array<{ + name: string + bridge: keyof typeof window.bridge + invoke: (a: RuntimePort) => Promise + expectArgs: unknown[] + }> = [ + { + name: 'getNetworkInterfaces', + bridge: 'etherCATGetInterfaces', + invoke: (a) => a.getNetworkInterfaces!(), + expectArgs: ['192.168.1.100'], + }, + { + name: 'getEthercatServiceStatus', + bridge: 'etherCATGetStatus', + invoke: (a) => a.getEthercatServiceStatus!(), + expectArgs: ['192.168.1.100'], + }, + { + name: 'scanEthercatDevices', + bridge: 'etherCATScan', + invoke: (a) => a.scanEthercatDevices!({ interface: 'eth0' } as never), + expectArgs: ['192.168.1.100', { interface: 'eth0' }], + }, + { + name: 'testEthercatConnection', + bridge: 'etherCATTest', + invoke: (a) => a.testEthercatConnection!({ slave: 1 } as never), + expectArgs: ['192.168.1.100', { slave: 1 }], + }, + { + name: 'validateEthercatConfig', + bridge: 'etherCATValidate', + invoke: (a) => a.validateEthercatConfig!({ config: {} } as never), + expectArgs: ['192.168.1.100', { config: {} }], + }, + { + name: 'getEthercatRuntimeStatus', + bridge: 'etherCATGetRuntimeStatus', + invoke: (a) => a.getEthercatRuntimeStatus!(), + expectArgs: ['192.168.1.100'], + }, + ] + + it.each(cases)('$name delegates to the bridge without a token', async ({ bridge, invoke, expectArgs }) => { + await invoke(adapter) + expect(window.bridge[bridge]).toHaveBeenCalledWith(...expectArgs) + }) + + it.each(cases)('$name surfaces a bridge error', async ({ bridge, invoke }) => { + ;(window.bridge[bridge] as jest.Mock).mockRejectedValueOnce(new Error('boom')) + const result = (await invoke(adapter)) as { success: boolean; error?: string } + expect(result).toEqual({ success: false, error: 'boom' }) + }) +}) + describe('isReadyForDebug', () => { it('returns false when no IP and no token', () => { mockIpAddress = '' diff --git a/src/middleware/adapters/editor/runtime-adapter.ts b/src/middleware/adapters/editor/runtime-adapter.ts index 04beacfad..c2e953f84 100644 --- a/src/middleware/adapters/editor/runtime-adapter.ts +++ b/src/middleware/adapters/editor/runtime-adapter.ts @@ -8,8 +8,11 @@ * Connection context: * - IP address: provided via getIpAddress() callback injected at creation. * Set by the store/UI when the user configures the device. - * - JWT token: managed internally. Stored after successful login(), - * updated on token-refresh events, cleared on clearCredentials(). + * - JWT token: owned entirely by the main process (the single token + * authority). The renderer no longer holds or passes the token — main + * injects it into every runtime call and refreshes it on expiry, then + * notifies the renderer via onTokenRefreshed. This adapter only tracks + * whether a session is active (for isReadyForDebug). */ import { getErrorMessage } from '../../../frontend/utils/get-error-message' @@ -28,7 +31,9 @@ import type { import type { SerialPort, Unsubscribe } from '../../shared/ports/types' export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimePort { - let jwtToken = '' + // Whether a runtime session is active. The token itself lives in the main + // process; this only gates isReadyForDebug. + let loggedIn = false function requireIp(): string { const ip = getIpAddress() @@ -38,7 +43,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP return { isReadyForDebug() { - return getIpAddress() !== '' && jwtToken !== '' + return getIpAddress() !== '' && loggedIn }, async login(params: LoginParams): Promise { @@ -46,7 +51,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP const ip = requireIp() const result = await window.bridge.runtimeLogin(ip, params.username, params.password) if (result.success && result.accessToken) { - jwtToken = result.accessToken + loggedIn = true } return result } catch (err) { @@ -75,7 +80,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getStatus(includeStats?: boolean): Promise { try { const ip = requireIp() - return await window.bridge.runtimeGetStatus(ip, jwtToken, includeStats) + return await window.bridge.runtimeGetStatus(ip, includeStats) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -84,7 +89,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async startPlc() { try { const ip = requireIp() - return await window.bridge.runtimeStartPlc(ip, jwtToken) + return await window.bridge.runtimeStartPlc(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -93,7 +98,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async stopPlc() { try { const ip = requireIp() - return await window.bridge.runtimeStopPlc(ip, jwtToken) + return await window.bridge.runtimeStopPlc(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -102,7 +107,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getLogs(minId?: number): Promise { try { const ip = requireIp() - return await window.bridge.runtimeGetLogs(ip, jwtToken, minId) + return await window.bridge.runtimeGetLogs(ip, minId) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -111,7 +116,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getSerialPorts(): Promise<{ success: boolean; ports?: SerialPort[]; error?: string }> { try { const ip = requireIp() - return await window.bridge.runtimeGetSerialPorts(ip, jwtToken) + return await window.bridge.runtimeGetSerialPorts(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -120,22 +125,19 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getCompilationStatus(): Promise { try { const ip = requireIp() - return await window.bridge.runtimeGetCompilationStatus(ip, jwtToken) + return await window.bridge.runtimeGetCompilationStatus(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } }, async clearCredentials() { - jwtToken = '' + loggedIn = false return window.bridge.runtimeClearCredentials() }, onTokenRefreshed(callback: (newToken: string) => void): Unsubscribe { - const handler = (_event: unknown, newToken: string) => { - jwtToken = newToken - callback(newToken) - } + const handler = (_event: unknown, newToken: string) => callback(newToken) return window.bridge.onRuntimeTokenRefreshed(handler) }, @@ -144,7 +146,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getNetworkInterfaces() { try { const ip = requireIp() - return await window.bridge.etherCATGetInterfaces(ip, jwtToken) + return await window.bridge.etherCATGetInterfaces(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -153,7 +155,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getEthercatServiceStatus() { try { const ip = requireIp() - return await window.bridge.etherCATGetStatus(ip, jwtToken) + return await window.bridge.etherCATGetStatus(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -162,7 +164,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async scanEthercatDevices(request) { try { const ip = requireIp() - return await window.bridge.etherCATScan(ip, jwtToken, request) + return await window.bridge.etherCATScan(ip, request) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -171,7 +173,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async testEthercatConnection(request) { try { const ip = requireIp() - return await window.bridge.etherCATTest(ip, jwtToken, request) + return await window.bridge.etherCATTest(ip, request) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -180,7 +182,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async validateEthercatConfig(request) { try { const ip = requireIp() - return await window.bridge.etherCATValidate(ip, jwtToken, request) + return await window.bridge.etherCATValidate(ip, request) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -189,7 +191,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getEthercatRuntimeStatus() { try { const ip = requireIp() - return await window.bridge.etherCATGetRuntimeStatus(ip, jwtToken) + return await window.bridge.etherCATGetRuntimeStatus(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index a4f5c3deb..e48ebefa8 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -180,6 +180,14 @@ export interface RuntimePort { */ onTokenRefreshed?(callback: (newToken: string) => void): Unsubscribe + /** + * Current runtime access token held by the platform's token authority, or + * null when not authenticated. Exposed so non-RuntimePort callers (e.g. the + * compile/upload pipeline) can read the always-fresh token from the single + * authority instead of a separately-tracked copy. + */ + getAccessToken?(): string | null + // --- LAN discovery (UDP broadcast) --- /** diff --git a/src/middleware/shared/runtime-auth/__tests__/runtime-token-manager.test.ts b/src/middleware/shared/runtime-auth/__tests__/runtime-token-manager.test.ts new file mode 100644 index 000000000..ecb4988b0 --- /dev/null +++ b/src/middleware/shared/runtime-auth/__tests__/runtime-token-manager.test.ts @@ -0,0 +1,257 @@ +import { createRuntimeTokenManager, type TokenLoginTransport } from '../runtime-token-manager' + +const CREDS = { username: 'admin', password: 'openplc' } + +/** + * A controllable login transport. Each call to `login` resolves with the next + * queued result (or a default success), and records the credentials it saw. + */ +function makeTransport(results?: Array<{ success: boolean; token?: string; error?: string }>) { + const queue = [...(results ?? [])] + const calls: Array<{ username: string; password: string }> = [] + let deferredResolvers: Array<(v: { success: boolean; token?: string }) => void> = [] + const transport: TokenLoginTransport & { + calls: typeof calls + resolveNext: (v: { success: boolean; token?: string }) => void + pending: number + } = { + calls, + get pending() { + return deferredResolvers.length + }, + resolveNext(v) { + const r = deferredResolvers.shift() + if (r) r(v) + }, + login(credentials) { + calls.push({ ...credentials }) + if (queue.length > 0) return Promise.resolve(queue.shift()!) + // No queued result → return a promise the test resolves manually (for + // single-flight timing tests). + return new Promise((resolve) => { + deferredResolvers.push(resolve) + }) + }, + } + return transport +} + +describe('createRuntimeTokenManager', () => { + describe('initial state', () => { + it('starts with no token', () => { + const m = createRuntimeTokenManager(makeTransport()) + expect(m.getToken()).toBeNull() + expect(m.hasToken()).toBe(false) + }) + }) + + describe('setSession / clear', () => { + it('adopts a token and reports hasToken', () => { + const m = createRuntimeTokenManager(makeTransport()) + m.setSession('tok-1', CREDS) + expect(m.getToken()).toBe('tok-1') + expect(m.hasToken()).toBe(true) + }) + + it('clear forgets the token and credentials (so refresh can no longer run)', async () => { + const m = createRuntimeTokenManager(makeTransport([{ success: true, token: 'x' }])) + m.setSession('tok-1', CREDS) + m.clear() + expect(m.getToken()).toBeNull() + expect(m.hasToken()).toBe(false) + // No credentials left → refresh is a no-op. + expect(await m.refresh()).toBe(false) + }) + + it('treats an empty-string token as not-held', () => { + const m = createRuntimeTokenManager(makeTransport()) + m.setSession('', CREDS) + expect(m.hasToken()).toBe(false) + }) + }) + + describe('refresh', () => { + it('returns false when there are no stored credentials', async () => { + const t = makeTransport() + const m = createRuntimeTokenManager(t) + expect(await m.refresh()).toBe(false) + expect(t.calls).toHaveLength(0) + }) + + it('re-authenticates with stored credentials and adopts the new token', async () => { + const t = makeTransport([{ success: true, token: 'tok-2' }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + expect(await m.refresh()).toBe(true) + expect(m.getToken()).toBe('tok-2') + expect(t.calls).toEqual([CREDS]) + }) + + it('returns false and keeps the old token when the runtime rejects re-login', async () => { + const t = makeTransport([{ success: false, error: 'bad creds' }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + expect(await m.refresh()).toBe(false) + expect(m.getToken()).toBe('tok-1') + }) + + it('returns false when login succeeds but yields no token', async () => { + const t = makeTransport([{ success: true }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + expect(await m.refresh()).toBe(false) + expect(m.getToken()).toBe('tok-1') + }) + + it('treats a thrown transport error as a failed refresh', async () => { + const t: TokenLoginTransport = { + login: () => Promise.reject(new Error('network down')), + } + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + expect(await m.refresh()).toBe(false) + expect(m.getToken()).toBe('tok-1') + }) + + it('is single-flight: concurrent refreshes share one login call', async () => { + const t = makeTransport() // no queued results → manual resolution + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + + const a = m.refresh() + const b = m.refresh() + // Both joined the same in-flight login. + expect(t.pending).toBe(1) + + t.resolveNext({ success: true, token: 'tok-2' }) + expect(await a).toBe(true) + expect(await b).toBe(true) + expect(t.calls).toHaveLength(1) + expect(m.getToken()).toBe('tok-2') + }) + + it('allows a fresh refresh after a previous one settled', async () => { + const t = makeTransport([ + { success: true, token: 'tok-2' }, + { success: true, token: 'tok-3' }, + ]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + expect(await m.refresh()).toBe(true) + expect(await m.refresh()).toBe(true) + expect(t.calls).toHaveLength(2) + expect(m.getToken()).toBe('tok-3') + }) + }) + + describe('withAuth', () => { + it('runs the operation with the current token and returns its result when authorized', async () => { + const m = createRuntimeTokenManager(makeTransport()) + m.setSession('tok-1', CREDS) + const seen: string[] = [] + const result = await m.withAuth( + (token) => { + seen.push(token) + return Promise.resolve({ code: 200 }) + }, + (r) => r.code === 401, + ) + expect(result).toEqual({ code: 200 }) + expect(seen).toEqual(['tok-1']) + }) + + it('refreshes and retries once with the new token on an unauthorized result', async () => { + const t = makeTransport([{ success: true, token: 'tok-2' }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + const seen: string[] = [] + const result = await m.withAuth( + (token) => { + seen.push(token) + return Promise.resolve({ code: token === 'tok-2' ? 200 : 401 }) + }, + (r) => r.code === 401, + ) + expect(result).toEqual({ code: 200 }) + expect(seen).toEqual(['tok-1', 'tok-2']) // first attempt, then retry with fresh token + }) + + it('returns the unauthorized result without retrying when refresh fails', async () => { + const t = makeTransport([{ success: false }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + let attempts = 0 + const result = await m.withAuth( + () => { + attempts += 1 + return Promise.resolve({ code: 401 }) + }, + (r) => r.code === 401, + ) + expect(result).toEqual({ code: 401 }) + expect(attempts).toBe(1) // no retry because refresh failed + }) + + it('does not retry when the result is authorized even if a refresh would succeed', async () => { + const t = makeTransport([{ success: true, token: 'tok-2' }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + const result = await m.withAuth( + () => Promise.resolve({ code: 200 }), + (r) => r.code === 401, + ) + expect(result).toEqual({ code: 200 }) + expect(t.calls).toHaveLength(0) // refresh never called + }) + + it('passes an empty string to the operation when there is no token yet', async () => { + const m = createRuntimeTokenManager(makeTransport()) + const seen: string[] = [] + await m.withAuth( + (token) => { + seen.push(token) + return Promise.resolve({ code: 200 }) + }, + (r) => r.code === 401, + ) + expect(seen).toEqual(['']) + }) + }) + + describe('onTokenChanged', () => { + it('notifies subscribers with the fresh token on refresh', async () => { + const t = makeTransport([{ success: true, token: 'tok-2' }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + const seen: string[] = [] + m.onTokenChanged((tok) => seen.push(tok)) + await m.refresh() + expect(seen).toEqual(['tok-2']) + }) + + it('stops notifying after unsubscribe', async () => { + const t = makeTransport([ + { success: true, token: 'tok-2' }, + { success: true, token: 'tok-3' }, + ]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + const seen: string[] = [] + const unsubscribe = m.onTokenChanged((tok) => seen.push(tok)) + await m.refresh() + unsubscribe() + await m.refresh() + expect(seen).toEqual(['tok-2']) // second refresh not observed + }) + + it('does not notify when a refresh fails', async () => { + const t = makeTransport([{ success: false }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + const seen: string[] = [] + m.onTokenChanged((tok) => seen.push(tok)) + await m.refresh() + expect(seen).toEqual([]) + }) + }) +}) diff --git a/src/middleware/shared/runtime-auth/runtime-token-manager.ts b/src/middleware/shared/runtime-auth/runtime-token-manager.ts new file mode 100644 index 000000000..4c7885841 --- /dev/null +++ b/src/middleware/shared/runtime-auth/runtime-token-manager.ts @@ -0,0 +1,143 @@ +/** + * RuntimeTokenManager — the single, platform-agnostic authority for a runtime + * session's JWT. + * + * The OpenPLC runtime issues short-lived access tokens (≈15 min) with no + * server-side sliding expiration, so a long-but-active session will otherwise + * have its token age out mid-use. Historically each call path (status polling, + * start/stop, project upload, EtherCAT) tracked and refreshed the token on its + * own — or not at all — which is why stats could keep working while an upload + * silently 401'd. This manager centralizes all of that so every runtime call + * shares one token, one credential set, and one refresh path. + * + * It is intentionally dependency-free pure TS: the same code runs in the web + * renderer adapter AND in the editor's Electron main process. Only the + * `login` transport differs per platform (orchestrator POST vs. direct HTTPS), + * which is injected. + * + * Behavior must be IDENTICAL on both platforms — this file is part of the + * byte-identical shared surface between openplc-web and openplc-editor. + */ + +export interface RuntimeCredentials { + username: string + password: string +} + +export interface TokenLoginResult { + success: boolean + token?: string + error?: string +} + +export interface TokenLoginTransport { + /** + * Platform-specific authentication: POST the credentials to the runtime and + * resolve with a fresh access token. The manager calls this for the initial + * refresh wiring and for every re-authentication; it never inspects how the + * request is transported. + */ + login(credentials: RuntimeCredentials): Promise +} + +export interface RuntimeTokenManager { + /** The current access token, or null when not authenticated. */ + getToken(): string | null + /** Whether a usable token is currently held. */ + hasToken(): boolean + /** Adopt a token + the credentials that produced it (called on login). */ + setSession(token: string, credentials: RuntimeCredentials): void + /** Forget the token and credentials (called on logout). */ + clear(): void + /** + * Re-authenticate with the stored credentials and adopt the fresh token. + * Resolves false when there is nothing to re-authenticate with or the runtime + * rejects the re-login. Concurrent calls share a single in-flight request + * (single-flight) so a burst of expired calls triggers exactly one re-login. + */ + refresh(): Promise + /** + * Run an authenticated operation with the current token. If the result is + * unauthorized, refresh once and retry exactly once with the new token. + * `isUnauthorized` lets the caller classify its own transport's result shape + * (HTTP 401/403, etc.) without this module knowing the transport. + */ + withAuth(operation: (token: string) => Promise, isUnauthorized: (result: T) => boolean): Promise + /** + * Subscribe to token changes (every successful refresh). Returns an + * unsubscribe function. Used to keep mirrors (e.g. the store connection flag) + * in sync. + */ + onTokenChanged(callback: (newToken: string) => void): () => void +} + +export function createRuntimeTokenManager(transport: TokenLoginTransport): RuntimeTokenManager { + let token: string | null = null + let credentials: RuntimeCredentials | null = null + let refreshInFlight: Promise | null = null + const subscribers = new Set<(newToken: string) => void>() + + function getToken(): string | null { + return token + } + + function hasToken(): boolean { + return token !== null && token !== '' + } + + function setSession(newToken: string, newCredentials: RuntimeCredentials): void { + token = newToken + credentials = newCredentials + } + + function clear(): void { + token = null + credentials = null + } + + function refresh(): Promise { + // Single-flight: a second caller that arrives while a re-login is in + // progress joins the same promise instead of firing another login. + if (refreshInFlight) return refreshInFlight + if (!credentials) return Promise.resolve(false) + + const pending = transport + .login(credentials) + .then((result) => { + if (result.success && result.token) { + token = result.token + const fresh = result.token + subscribers.forEach((cb) => cb(fresh)) + return true + } + return false + }) + .catch(() => false) + .finally(() => { + refreshInFlight = null + }) + + refreshInFlight = pending + return pending + } + + async function withAuth( + operation: (token: string) => Promise, + isUnauthorized: (result: T) => boolean, + ): Promise { + const result = await operation(token ?? '') + if (isUnauthorized(result) && (await refresh())) { + return operation(token ?? '') + } + return result + } + + function onTokenChanged(callback: (newToken: string) => void): () => void { + subscribers.add(callback) + return () => { + subscribers.delete(callback) + } + } + + return { getToken, hasToken, setSession, clear, refresh, withAuth, onTokenChanged } +}