fix(office-excel): support Office.js add-in embed and surface Graph errors (#4479)

* fix(office-excel): support Office.js add-in embed and surface Graph errors

* fix(office-excel): delegate to parseGraphErrorFromData and handle array embed param
This commit is contained in:
Waleed
2026-05-06 13:03:09 -07:00
committed by GitHub
parent 79ffccc140
commit 369f9b613d
13 changed files with 354 additions and 42 deletions
@@ -7,7 +7,7 @@ import { validatePathSegment, validateSharePointSiteId } from '@/lib/core/securi
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils'
import { extractGraphError, GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils'
export const dynamic = 'force-dynamic'
@@ -76,13 +76,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: { message: 'Unknown error' } }))
return NextResponse.json(
{ error: errorData.error?.message || 'Failed to fetch drive' },
{ status: response.status }
)
const errorMessage = await extractGraphError(response)
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data: GraphDrive = await response.json()
@@ -102,15 +97,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
const errorMessage = await extractGraphError(response)
logger.error(`[${requestId}] Microsoft Graph API error fetching drives`, {
status: response.status,
error: errorData.error?.message,
error: errorMessage,
})
return NextResponse.json(
{ error: errorData.error?.message || 'Failed to fetch drives' },
{ status: response.status }
)
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
@@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getItemBasePath } from '@/tools/microsoft_excel/utils'
import { extractGraphError, getItemBasePath } from '@/tools/microsoft_excel/utils'
export const dynamic = 'force-dynamic'
@@ -73,18 +73,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
})
if (!worksheetsResponse.ok) {
const errorData = await worksheetsResponse
.text()
.then((text) => JSON.parse(text))
.catch(() => ({ error: { message: 'Unknown error' } }))
const errorMessage = await extractGraphError(worksheetsResponse)
logger.error(`[${requestId}] Microsoft Graph API error`, {
status: worksheetsResponse.status,
error: errorData.error?.message || 'Failed to fetch worksheets',
error: errorMessage,
})
return NextResponse.json(
{ error: errorData.error?.message || 'Failed to fetch worksheets' },
{ status: worksheetsResponse.status }
)
return NextResponse.json({ error: errorMessage }, { status: worksheetsResponse.status })
}
const data: WorksheetsResponse = await worksheetsResponse.json()
@@ -0,0 +1,50 @@
'use client'
import Script from 'next/script'
declare global {
interface Window {
Office?: {
onReady: () => Promise<{ host: string | null; platform: string | null }>
}
}
}
/**
* Office.js nullifies window.history.replaceState and pushState (a legacy
* IE10 workaround inside the library) which breaks Next.js's client-side
* router. Cache the originals at module load — before <Script> renders
* Office.js into the DOM — so we can restore them after it loads.
*
* See https://learn.microsoft.com/en-us/answers/questions/1070090/using-office-javascript-api-in-next-js.
*/
const cachedHistory =
typeof window !== 'undefined'
? {
replaceState: window.history.replaceState.bind(window.history),
pushState: window.history.pushState.bind(window.history),
}
: null
/**
* Loads Office.js and signals readiness so Office host applications
* (Excel, Word, PowerPoint, Outlook) recognize this page as a valid add-in.
*
* Office.onReady() must be called once Office.js is loaded — see
* https://learn.microsoft.com/en-us/javascript/api/office#office-office-onready-function(1).
*/
export function OfficeEmbedInit() {
return (
<Script
src='https://appsforoffice.microsoft.com/lib/1/hosted/office.js'
strategy='afterInteractive'
onReady={() => {
if (cachedHistory) {
window.history.replaceState = cachedHistory.replaceState
window.history.pushState = cachedHistory.pushState
}
void window.Office?.onReady()
}}
/>
)
}
+17 -2
View File
@@ -1,11 +1,26 @@
import type { Metadata } from 'next'
import ChatClient from '@/app/chat/[identifier]/chat'
import { OfficeEmbedInit } from '@/app/chat/[identifier]/office-embed-init'
export const metadata: Metadata = {
title: 'Chat',
}
export default async function ChatPage({ params }: { params: Promise<{ identifier: string }> }) {
export default async function ChatPage({
params,
searchParams,
}: {
params: Promise<{ identifier: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const { identifier } = await params
return <ChatClient key={identifier} identifier={identifier} />
const { embed } = await searchParams
const isOfficeEmbed = embed === 'office' || (Array.isArray(embed) && embed.includes('office'))
return (
<>
{isOfficeEmbed && <OfficeEmbedInit />}
<ChatClient key={identifier} identifier={identifier} />
</>
)
}
+19
View File
@@ -25,6 +25,7 @@ import {
buildTimeCSPDirectives,
type CSPDirectives,
generateRuntimeCSP,
getChatEmbedCSPPolicy,
getMainCSPPolicy,
getWorkflowExecutionCSPPolicy,
removeCSPSource,
@@ -278,3 +279,21 @@ describe('buildTimeCSPDirectives', () => {
expect(buildTimeCSPDirectives['img-src']).toContain('blob:')
})
})
describe('getChatEmbedCSPPolicy', () => {
it('allows iframe embedding from any origin', () => {
expect(getChatEmbedCSPPolicy()).toContain('frame-ancestors *')
})
it('allows Office.js to load from Microsoft for Excel/Word add-in embedding', () => {
const policy = getChatEmbedCSPPolicy()
expect(policy).toMatch(/script-src[^;]*https:\/\/appsforoffice\.microsoft\.com/)
expect(policy).toMatch(/connect-src[^;]*https:\/\/appsforoffice\.microsoft\.com/)
})
it('does not regress object-src or base-uri restrictions', () => {
const policy = getChatEmbedCSPPolicy()
expect(policy).toContain("object-src 'none'")
expect(policy).toContain("base-uri 'self'")
})
})
+13 -2
View File
@@ -236,10 +236,21 @@ function getEmbedCSPPolicy(): string {
}
/**
* CSP for embeddable chat pages
* CSP for embeddable chat pages.
* Extends the shared embed policy with Microsoft Office.js sources so the
* chat page can serve as an Office (Excel/Word/Outlook) add-in surface
* when loaded with `?embed=office`.
*/
export function getChatEmbedCSPPolicy(): string {
return getEmbedCSPPolicy()
return buildCSPString({
...buildTimeCSPDirectives,
'script-src': [...STATIC_SCRIPT_SRC, 'https://appsforoffice.microsoft.com'],
'connect-src': [
...(buildTimeCSPDirectives['connect-src'] ?? []),
'https://appsforoffice.microsoft.com',
],
'frame-ancestors': ['*'],
})
}
/**
+10
View File
@@ -19,6 +19,8 @@
* 2. Add the ID to ErrorExtractorId constant at the bottom of this file
*/
import { parseGraphErrorFromData } from '@/tools/microsoft_excel/utils'
export interface ErrorInfo {
status?: number
statusText?: string
@@ -184,6 +186,13 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
examples: ['Microsoft OAuth', 'Google OAuth', 'OAuth2 providers'],
extract: (errorInfo) => errorInfo?.data?.error_description,
},
{
id: 'microsoft-graph-errors',
description:
'Microsoft Graph error format with nested innerError chain and details[] (Excel, OneDrive, SharePoint, Outlook). See https://learn.microsoft.com/en-us/graph/errors',
examples: ['Microsoft Excel', 'Microsoft OneDrive', 'Microsoft SharePoint'],
extract: (errorInfo) => parseGraphErrorFromData(errorInfo?.data),
},
{
id: 'nested-error-object',
description: 'Error field containing nested object or string',
@@ -260,6 +269,7 @@ export function extractErrorMessage(errorInfo?: ErrorInfo, extractorId?: string)
export const ErrorExtractorId = {
ATLASSIAN_ERRORS: 'atlassian-errors',
MICROSOFT_GRAPH_ERRORS: 'microsoft-graph-errors',
GRAPHQL_ERRORS: 'graphql-errors',
TWITTER_ERRORS: 'twitter-errors',
DETAILS_ARRAY: 'details-array',
+17 -1
View File
@@ -1,3 +1,4 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import type {
ExcelCellValue,
MicrosoftExcelReadResponse,
@@ -8,15 +9,25 @@ import type {
import {
getItemBasePath,
getSpreadsheetWebUrl,
parseGraphErrorMessage,
trimTrailingEmptyRowsAndColumns,
} from '@/tools/microsoft_excel/utils'
import type { ToolConfig } from '@/tools/types'
const EXCEL_RETRY_CONFIG = {
enabled: true,
maxRetries: 3,
initialDelayMs: 500,
maxDelayMs: 30000,
retryIdempotentOnly: true,
} as const
export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadResponse> = {
id: 'microsoft_excel_read',
name: 'Read from Microsoft Excel',
description: 'Read data from a Microsoft Excel spreadsheet',
version: '1.0',
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
oauth: {
required: true,
@@ -95,6 +106,7 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
Authorization: `Bearer ${params.accessToken}`,
}
},
retry: EXCEL_RETRY_CONFIG,
},
transformResponse: async (response: Response, params?: MicrosoftExcelToolParams) => {
@@ -123,8 +135,10 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
})
if (!rangeResp.ok) {
const errorText = await rangeResp.text().catch(() => '')
const detail = parseGraphErrorMessage(rangeResp.status, rangeResp.statusText, errorText)
throw new Error(
'Invalid range provided or worksheet not found. Provide a range like "Sheet1!A1:B2" or just the sheet name to read the whole sheet'
`Failed to read worksheet "${firstSheetName}": ${detail}. Provide a range like "Sheet1!A1:B2" or just the sheet name to read the whole sheet.`
)
}
@@ -209,6 +223,7 @@ export const readV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2
name: 'Read from Microsoft Excel V2',
description: 'Read data from a specific sheet in a Microsoft Excel spreadsheet',
version: '2.0.0',
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
oauth: {
required: true,
@@ -284,6 +299,7 @@ export const readV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2
Authorization: `Bearer ${params.accessToken}`,
}
},
retry: EXCEL_RETRY_CONFIG,
},
transformResponse: async (response: Response, params?: MicrosoftExcelV2ToolParams) => {
@@ -1,3 +1,4 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import type {
MicrosoftExcelTableAddResponse,
MicrosoftExcelTableToolParams,
@@ -13,6 +14,7 @@ export const tableAddTool: ToolConfig<
name: 'Add to Microsoft Excel Table',
description: 'Add new rows to a Microsoft Excel table',
version: '1.0',
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
oauth: {
required: true,
@@ -0,0 +1,90 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { parseGraphErrorMessage } from '@/tools/microsoft_excel/utils'
describe('parseGraphErrorMessage', () => {
it('extracts top-level error.message', () => {
const body = JSON.stringify({
error: { code: 'badRequest', message: 'Uploaded fragment overlaps with existing data.' },
})
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe(
'Uploaded fragment overlaps with existing data.'
)
})
it('combines top-level and innerError messages with em-dash separator', () => {
const body = JSON.stringify({
error: {
code: 'invalidRequest',
message: 'The request is invalid.',
innerError: { code: 'invalidRange', message: 'Range A1:Z9999 is out of bounds.' },
},
})
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe(
'The request is invalid. — Range A1:Z9999 is out of bounds.'
)
})
it('walks nested innerError chain (lowercase spec form)', () => {
const body = JSON.stringify({
error: {
message: 'Outer message.',
innererror: {
message: 'Middle message.',
innererror: { message: 'Innermost message.' },
},
},
})
expect(parseGraphErrorMessage(500, 'Internal Server Error', body)).toBe(
'Outer message. — Middle message. — Innermost message.'
)
})
it('appends details[].message entries', () => {
const body = JSON.stringify({
error: {
message: 'Multiple problems.',
details: [{ message: 'Cell A1 invalid.' }, { message: 'Cell B2 invalid.' }],
},
})
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe(
'Multiple problems. — Cell A1 invalid. — Cell B2 invalid.'
)
})
it('falls back to error.code when no messages present', () => {
const body = JSON.stringify({ error: { code: 'itemNotFound' } })
expect(parseGraphErrorMessage(404, 'Not Found', body)).toBe('itemNotFound (404 Not Found)')
})
it('returns raw text when body is not JSON', () => {
expect(parseGraphErrorMessage(502, 'Bad Gateway', 'upstream timeout')).toBe('upstream timeout')
})
it('falls back to status text when body is empty', () => {
expect(parseGraphErrorMessage(503, 'Service Unavailable', '')).toBe('503 Service Unavailable')
})
it('handles deeply nested chain without infinite loop', () => {
let nested: Record<string, unknown> = { message: 'leaf' }
for (let i = 0; i < 50; i++) {
nested = { message: `level-${i}`, innerError: nested }
}
const body = JSON.stringify({ error: nested })
const result = parseGraphErrorMessage(500, 'Internal Server Error', body)
// Should include outer plus capped nested messages, not blow up.
expect(result.startsWith('level-49')).toBe(true)
})
it('deduplicates identical inner messages', () => {
const body = JSON.stringify({
error: {
message: 'Same message.',
innerError: { message: 'Same message.' },
},
})
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe('Same message.')
})
})
+106
View File
@@ -4,6 +4,112 @@ import type { ExcelCellValue } from '@/tools/microsoft_excel/types'
const logger = createLogger('MicrosoftExcelUtils')
/**
* Extract a developer-readable message from a parsed Microsoft Graph error body.
* Graph errors follow the documented shape:
* { error: { code, message, innerError: { code, message, ... }, details: [...] } }
* See https://learn.microsoft.com/en-us/graph/errors
*
* Walks the nested innerError chain (capped at depth 5) and appends details[].message.
* Returns undefined when no message-like field is present so callers can fall back.
*/
export function parseGraphErrorFromData(data: unknown): string | undefined {
if (!data || typeof data !== 'object') return undefined
const root = (
data as {
error?: {
code?: unknown
message?: unknown
innerError?: unknown
innererror?: unknown
details?: unknown
}
}
).error
if (root && typeof root === 'object') {
const messages: string[] = []
if (typeof root.message === 'string' && root.message.trim()) {
messages.push(root.message.trim())
}
// Walk the (possibly nested) innerError chain. Spec uses `innererror`
// but Graph commonly returns `innerError` — accept both.
let inner: any = (root as any).innererror ?? (root as any).innerError
let depth = 0
while (inner && depth < 5) {
if (typeof inner.message === 'string' && inner.message.trim()) {
const msg = inner.message.trim()
if (!messages.includes(msg)) messages.push(msg)
}
inner = inner.innererror ?? inner.innerError
depth++
}
if (Array.isArray((root as any).details)) {
for (const detail of (root as any).details) {
if (detail && typeof detail.message === 'string' && detail.message.trim()) {
const msg = detail.message.trim()
if (!messages.includes(msg)) messages.push(msg)
}
}
}
if (messages.length > 0) return messages.join(' — ')
if (typeof root.code === 'string' && root.code.trim()) {
return root.code.trim()
}
}
const topMessage = (data as { message?: unknown }).message
if (typeof topMessage === 'string' && topMessage.trim()) {
return topMessage.trim()
}
return undefined
}
/**
* Parse a Microsoft Graph error response body into a string message.
* Used by API routes that have a Response object rather than parsed data.
*/
export function parseGraphErrorMessage(
status: number,
statusText: string,
errorText: string
): string {
try {
const data = JSON.parse(errorText)
const message = parseGraphErrorFromData(data)
if (message) {
// If the only thing we found was the bare error code, append status for context.
const root = data?.error
if (
root &&
message === root.code?.trim?.() &&
!(typeof root.message === 'string' && root.message.trim())
) {
return `${message} (${status} ${statusText})`
}
return message
}
} catch {
if (errorText?.trim()) return errorText.trim()
}
return statusText ? `${status} ${statusText}` : `Microsoft Graph request failed (${status})`
}
/**
* Read an error response body and produce a developer-readable message.
* Safely handles non-JSON bodies and read failures. Used by internal API routes.
*/
export async function extractGraphError(response: Response): Promise<string> {
const errorText = await response.text().catch(() => '')
return parseGraphErrorMessage(response.status, response.statusText, errorText)
}
/** Pattern for Microsoft Graph item/drive IDs: alphanumeric, hyphens, underscores, and ! (for SharePoint b!<base64> format) */
export const GRAPH_ID_PATTERN = /^[a-zA-Z0-9!_-]+$/
@@ -1,3 +1,4 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import type {
MicrosoftExcelWorksheetAddResponse,
MicrosoftExcelWorksheetToolParams,
@@ -17,6 +18,7 @@ export const worksheetAddTool: ToolConfig<
name: 'Add Worksheet to Microsoft Excel',
description: 'Create a new worksheet (sheet) in a Microsoft Excel workbook',
version: '1.0',
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
oauth: {
required: true,
@@ -99,19 +101,6 @@ export const worksheetAddTool: ToolConfig<
},
transformResponse: async (response: Response, params?: MicrosoftExcelWorksheetToolParams) => {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData?.error?.message || `Failed to create worksheet: ${response.statusText}`
// Handle specific error cases
if (response.status === 409) {
throw new Error('A worksheet with this name already exists. Please choose a different name')
}
throw new Error(errorMessage)
}
const data = await response.json()
const spreadsheetId = params?.spreadsheetId?.trim() || ''
+18
View File
@@ -1,3 +1,4 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import type {
MicrosoftExcelToolParams,
MicrosoftExcelV2ToolParams,
@@ -7,11 +8,25 @@ import type {
import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
import type { ToolConfig } from '@/tools/types'
/**
* Range writes (PATCH /workbook/.../range) are semantically idempotent —
* the same payload produces the same result — so we permit retries on
* transient 429/5xx even though PATCH is not in the default idempotent set.
*/
const EXCEL_RETRY_CONFIG = {
enabled: true,
maxRetries: 3,
initialDelayMs: 500,
maxDelayMs: 30000,
retryIdempotentOnly: false,
} as const
export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWriteResponse> = {
id: 'microsoft_excel_write',
name: 'Write to Microsoft Excel',
description: 'Write data to a Microsoft Excel spreadsheet',
version: '1.0',
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
oauth: {
required: true,
@@ -140,6 +155,7 @@ export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWrite
return body
},
retry: EXCEL_RETRY_CONFIG,
},
transformResponse: async (response: Response, params?: MicrosoftExcelToolParams) => {
@@ -190,6 +206,7 @@ export const writeV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV
name: 'Write to Microsoft Excel V2',
description: 'Write data to a specific sheet in a Microsoft Excel spreadsheet',
version: '2.0.0',
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
oauth: {
required: true,
@@ -326,6 +343,7 @@ export const writeV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV
return body
},
retry: EXCEL_RETRY_CONFIG,
},
transformResponse: async (response: Response, params?: MicrosoftExcelV2ToolParams) => {