import express from 'express'; import { readTrimmedString } from './parse.js'; const PENDING_JSON_LIMIT = '16kb'; const parseJsonBody = express.json({ limit: PENDING_JSON_LIMIT }); function queryValue(req, key) { const raw = req.query?.[key]; const value = Array.isArray(raw) ? raw[0] : raw; return readTrimmedString(value); } function isLinearUserError(error) { return error?.code === 'INVALID' || error?.userError === true; } function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function renderLinearOAuthCallbackPage({ title, message, desktopReturn }) { return ` ${escapeHtml(title)} — OpenChamber

${escapeHtml(title)}

${escapeHtml(message)}

${desktopReturn ? `Return to OpenChamber ` : ''}
`; } async function storeAuthorizationResult(libraries, result) { const { setLinearAuth, fetchLinearIdentity } = libraries; let user = null; let organization = null; try { const identity = await fetchLinearIdentity(result.accessToken); user = identity.user; organization = identity.organization; } catch (error) { console.error('Failed to load Linear identity after OAuth:', error); } return setLinearAuth({ accessToken: result.accessToken, refreshToken: result.refreshToken, tokenType: result.tokenType, expiresAt: result.expiresAt, scope: result.scope, user, organization, }); } export function registerLinearRoutes(app) { let linearLibraries = null; const getLinearLibraries = async () => { if (!linearLibraries) { linearLibraries = await import('./index.js'); } return linearLibraries; }; app.get('/linear/oauth/callback', async (req, res) => { const finish = (status, { title, message, desktopReturn = false }) => { res.status(status).type('html').send(renderLinearOAuthCallbackPage({ title, message, desktopReturn })); }; try { const libraries = await getLinearLibraries(); const { consumeAuthorizationCallback } = libraries; const result = await consumeAuthorizationCallback({ code: queryValue(req, 'code'), state: queryValue(req, 'state'), error: queryValue(req, 'error'), errorDescription: queryValue(req, 'error_description'), }); await storeAuthorizationResult(libraries, result); return finish(200, { title: 'Authorization Complete', message: 'You can close this tab and return to OpenChamber.', desktopReturn: result.origin === 'desktop', }); } catch (error) { const code = error instanceof Error ? error.code : ''; const status = code === 'UNKNOWN_STATE' || code === 'MISSING_CODE' || code === 'ACCESS_DENIED' ? 400 : 502; return finish(status, { title: 'Authorization Failed', message: error instanceof Error ? error.message : 'Linear authorization failed. Return to OpenChamber and click Connect again.', desktopReturn: error?.origin === 'desktop', }); } }); app.get('/api/linear/auth/status', async (_req, res) => { try { const libraries = await getLinearLibraries(); const { getLinearAuth, getLinearAuthWorkspaces, getValidLinearAccessToken, fetchLinearIdentity, setLinearAuth, clearLinearAuth, toLinearPublicStatus, pollAuthorizationBroker, completeAuthorizationBroker, } = libraries; try { const result = await pollAuthorizationBroker(); if (result) { await storeAuthorizationResult(libraries, result); await completeAuthorizationBroker(result.brokerReceipt).catch((error) => { console.warn('Failed to acknowledge Linear authorization broker result:', error); }); } } catch (error) { console.error('Failed to complete Linear authorization through broker:', error); } const accessToken = await getValidLinearAccessToken(); if (!accessToken) { return res.json({ connected: false }); } const auth = getLinearAuth(); try { const identity = await fetchLinearIdentity(accessToken); const next = setLinearAuth({ accessToken, refreshToken: auth?.refreshToken, tokenType: auth?.tokenType, expiresAt: auth?.expiresAt, scope: auth?.scope, user: identity.user, organization: identity.organization, workspaceId: auth?.workspaceId, }, { activate: false }); return res.json(toLinearPublicStatus(next, getLinearAuthWorkspaces())); } catch (error) { if (error?.status === 401) { clearLinearAuth(auth?.workspaceId); const remaining = getLinearAuth(); if (!remaining) { return res.json({ connected: false }); } return res.json(toLinearPublicStatus(remaining, getLinearAuthWorkspaces())); } if (auth) { return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces())); } throw error; } } catch (error) { console.error('Failed to get Linear auth status:', error); return res.status(500).json({ error: error.message || 'Failed to get Linear auth status' }); } }); app.post('/api/linear/auth/start', parseJsonBody, async (req, res) => { try { const { startAuthorization } = await getLinearLibraries(); const origin = req.body?.origin === 'desktop' ? 'desktop' : 'web'; const payload = await startAuthorization({ origin }); return res.json(payload); } catch (error) { const status = error?.code === 'LINEAR_CLIENT_ID_MISSING' ? 400 : 500; console.error('Failed to start Linear authorization:', error); return res.status(status).json({ error: error.message || 'Failed to start Linear authorization' }); } }); app.get('/api/linear/issues/list', async (req, res) => { try { const { listLinearIssues } = await getLinearLibraries(); const result = await listLinearIssues({ query: queryValue(req, 'query'), cursor: queryValue(req, 'cursor'), status: queryValue(req, 'status'), assignee: queryValue(req, 'assignee'), teamId: queryValue(req, 'teamId'), priority: queryValue(req, 'priority'), }); return res.json(result); } catch (error) { console.error('Failed to list Linear issues:', error); return res.status(500).json({ error: error.message || 'Failed to list Linear issues' }); } }); app.get('/api/linear/issues/get', async (req, res) => { try { const id = queryValue(req, 'id'); if (!id) { return res.status(400).json({ error: 'id is required' }); } const { getLinearIssue } = await getLinearLibraries(); const result = await getLinearIssue(id); return res.json(result); } catch (error) { console.error('Failed to load Linear issue:', error); return res.status(500).json({ error: error.message || 'Failed to load Linear issue' }); } }); app.get('/api/linear/issues/states', async (req, res) => { try { const teamId = queryValue(req, 'teamId'); if (!teamId) { return res.status(400).json({ error: 'teamId is required' }); } const { listLinearIssueStates } = await getLinearLibraries(); const result = await listLinearIssueStates(teamId); return res.json(result); } catch (error) { if (isLinearUserError(error)) { return res.status(400).json({ error: error.message }); } console.error('Failed to load Linear workflow states:', error); return res.status(500).json({ error: error.message || 'Failed to load Linear workflow states' }); } }); app.post('/api/linear/issues/update', parseJsonBody, async (req, res) => { try { const { updateLinearIssue } = await getLinearLibraries(); const result = await updateLinearIssue({ id: req.body?.id, stateId: req.body?.stateId, }); return res.json(result); } catch (error) { if (isLinearUserError(error)) { return res.status(400).json({ error: error.message }); } console.error('Failed to update Linear issue:', error); return res.status(500).json({ error: error.message || 'Failed to update Linear issue' }); } }); app.get('/api/linear/mapping', async (_req, res) => { try { const { listLinearTeams, readStoredLinearMapping, mergeLinearMappingView, LinearMappingError, } = await getLinearLibraries(); const teamsResult = await listLinearTeams(); if (teamsResult.connected === false) { return res.json({ connected: false }); } let stored; try { stored = readStoredLinearMapping(); } catch (error) { if (error instanceof LinearMappingError && error.code === 'MALFORMED') { return res.status(500).json({ error: error.message }); } throw error; } return res.json({ connected: true, ...mergeLinearMappingView(stored, teamsResult.teams), }); } catch (error) { console.error('Failed to load Linear mapping:', error); return res.status(500).json({ error: error.message || 'Failed to load Linear mapping' }); } }); app.put('/api/linear/mapping', parseJsonBody, async (req, res) => { try { const { getValidLinearAccessToken, listLinearTeams, setStoredLinearMapping, mergeLinearMappingView, LinearMappingError, } = await getLinearLibraries(); const accessToken = await getValidLinearAccessToken(); if (!accessToken) { return res.json({ connected: false }); } let stored; try { stored = setStoredLinearMapping(req.body); } catch (error) { if (error instanceof LinearMappingError && error.code === 'INVALID') { return res.status(400).json({ error: error.message }); } throw error; } const teamsResult = await listLinearTeams(); if (teamsResult.connected === false) { return res.json({ connected: true, ...mergeLinearMappingView(stored, []), }); } return res.json({ connected: true, ...mergeLinearMappingView(stored, teamsResult.teams), }); } catch (error) { console.error('Failed to save Linear mapping:', error); return res.status(500).json({ error: error.message || 'Failed to save Linear mapping' }); } }); app.post('/api/linear/session-status', parseJsonBody, async (req, res) => { try { const { postLinearSessionStatus, LinearSessionStatusError } = await getLinearLibraries(); try { const result = await postLinearSessionStatus({ kind: req.body?.kind, sessionId: req.body?.sessionId, issueIdentifier: req.body?.issueIdentifier, sessionOrigin: req.body?.sessionOrigin, }); return res.json(result); } catch (error) { if (error instanceof LinearSessionStatusError && error.code === 'INVALID') { return res.status(400).json({ error: error.message }); } if (error instanceof LinearSessionStatusError && error.code === 'MALFORMED') { return res.status(500).json({ error: error.message }); } throw error; } } catch (error) { console.error('Failed to post Linear session status:', error); return res.status(500).json({ error: error.message || 'Failed to post Linear session status' }); } }); app.get('/api/linear/preferences', async (_req, res) => { try { const { getLinearSessionCommentsEnabled } = await getLinearLibraries(); return res.json({ sessionComments: getLinearSessionCommentsEnabled() }); } catch (error) { console.error('Failed to load Linear preferences:', error); return res.status(500).json({ error: error.message || 'Failed to load Linear preferences' }); } }); app.put('/api/linear/preferences', parseJsonBody, async (req, res) => { try { const sessionComments = req.body?.sessionComments; if (sessionComments !== true && sessionComments !== false) { return res.status(400).json({ error: 'sessionComments must be a boolean' }); } const { setLinearSessionCommentsEnabled } = await getLinearLibraries(); return res.json({ sessionComments: setLinearSessionCommentsEnabled(sessionComments) }); } catch (error) { console.error('Failed to save Linear preferences:', error); return res.status(500).json({ error: error.message || 'Failed to save Linear preferences' }); } }); app.post('/api/linear/auth/activate', parseJsonBody, async (req, res) => { try { const { activateLinearAuth, getLinearAuth, getLinearAuthWorkspaces, toLinearPublicStatus, } = await getLinearLibraries(); const organizationId = readTrimmedString(req.body?.organizationId); if (!organizationId) { return res.status(400).json({ error: 'organizationId is required' }); } const activated = activateLinearAuth(organizationId); if (!activated) { return res.status(404).json({ error: 'Linear workspace not found' }); } const auth = getLinearAuth(); if (!auth) { return res.json({ connected: false }); } return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces())); } catch (error) { console.error('Failed to switch Linear workspace:', error); return res.status(500).json({ error: error.message || 'Failed to switch Linear workspace' }); } }); app.delete('/api/linear/auth', async (_req, res) => { try { const { getLinearAuth, clearLinearAuth, revokeToken } = await getLinearLibraries(); const auth = getLinearAuth(); if (auth?.refreshToken) { await revokeToken(auth.refreshToken, 'refresh_token'); } else if (auth?.accessToken) { await revokeToken(auth.accessToken, 'access_token'); } const removed = clearLinearAuth(auth?.workspaceId); return res.json({ success: true, removed }); } catch (error) { console.error('Failed to disconnect Linear:', error); return res.status(500).json({ error: error.message || 'Failed to disconnect Linear' }); } }); }