import { createAuthenticatedFetch } from 'kody:runtime'
import { resolveMicrosoftIntegration, type MicrosoftAccountParams } from './accounts.ts'
import {
GRAPH_API_BASE_URL,
type GraphMethod,
inferRequiredScopes,
reconnectUrl,
} from './scopes.ts'
export type JsonObject = { [key: string]: unknown }
export type GraphRequestParams = MicrosoftAccountParams & {
path: string
method?: GraphMethod
query?: Record<string, string | number | boolean | undefined>
body?: unknown
headers?: Record<string, string>
/**
* Graph $search on messages/events requires this header. Set automatically
* when `query.$search` is present.
*/
consistencyLevel?: string
}
export class MicrosoftGraphError extends Error {
readonly status: number
readonly code: string | null
readonly path: string
readonly method: GraphMethod
readonly integrationName: string
readonly missingScopes: string[]
readonly reconnectUrl: string
readonly details: unknown
constructor(input: {
message: string
status: number
code?: string | null
path: string
method: GraphMethod
integrationName: string
missingScopes: string[]
reconnectUrl: string
details?: unknown
}) {
super(input.message)
this.name = 'MicrosoftGraphError'
this.status = input.status
this.code = input.code ?? null
this.path = input.path
this.method = input.method
this.integrationName = input.integrationName
this.missingScopes = input.missingScopes
this.reconnectUrl = input.reconnectUrl
this.details = input.details ?? null
}
}
export function isReadOnlyMethod(method: GraphMethod): boolean {
switch (method) {
case 'GET':
return true
case 'POST':
case 'PATCH':
case 'PUT':
case 'DELETE':
return false
default: {
const exhaustive: never = method
return exhaustive
}
}
}
export function assertGraphPath(path: string): string {
if (!path.startsWith('/')) {
throw new Error("path must be an absolute Microsoft Graph path such as '/me' or '/me/messages'.")
}
if (!/^\/[A-Za-z0-9/_.~(),'$*=:!@+%-]+$/.test(path.split('?')[0] || path)) {
throw new Error("path contains characters that are not valid in a Microsoft Graph path.")
}
return path
}
function appendQuery(url: URL, query: GraphRequestParams['query']): void {
if (!query) return
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue
url.searchParams.set(key, String(value))
}
}
function graphErrorFields(data: unknown): { code: string | null; message: string | null } {
if (!data || typeof data !== 'object') return { code: null, message: null }
const record = data as JsonObject
const error = record.error
if (error && typeof error === 'object') {
const nested = error as JsonObject
return {
code: typeof nested.code === 'string' ? nested.code : null,
message: typeof nested.message === 'string' ? nested.message : null,
}
}
if (typeof error === 'string') return { code: error, message: null }
return { code: null, message: null }
}
function scopesMentionedInMessage(message: string | null): string[] {
if (!message) return []
const found = message.match(
/\b(?:Mail|Calendars|Tasks|Files|Chat|ChannelMessage|Channel|Team|User|MailboxSettings|offline_access)\.[A-Za-z.]+|\b(?:openid|profile|email|offline_access)\b/g,
)
return found ? [...new Set(found)] : []
}
function buildInsufficientScopeMessage(input: {
status: number
method: GraphMethod
path: string
integrationName: string
code: string | null
apiMessage: string | null
missingScopes: string[]
product: string
setupNote: string
reconnect: string
}): string {
const scopeList = input.missingScopes.join(', ')
const apiBit = input.apiMessage ? ' Graph said: ' + input.apiMessage : ''
const codeBit = input.code ? ' (' + input.code + ')' : ''
return (
'Microsoft Graph ' +
input.status +
codeBit +
' on ' +
input.method +
' ' +
input.path +
' using integration "' +
input.integrationName +
'". This ' +
input.product +
' call needs delegated scope' +
(input.missingScopes.length === 1 ? ' ' : 's ') +
scopeList +
'.' +
apiBit +
' ' +
input.setupNote +
' Next step: reconnect at ' +
input.reconnect +
' After Azure permissions change, the current token is unchanged until that reconnect finishes. Work/school tenants may also need admin consent for the same permission.'
)
}
async function parseBody(response: Response): Promise<unknown> {
if (response.status === 204) return null
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text) as unknown
} catch {
return text
}
}
/**
* Authenticated Microsoft Graph request for a saved OAuth integration.
* Throws MicrosoftGraphError with the missing delegated scope and reconnect URL on 403.
*/
export async function graphRequest<T = unknown>(params: GraphRequestParams): Promise<T> {
const integrationName = resolveMicrosoftIntegration(params)
const method: GraphMethod = params.method ?? (params.body === undefined ? 'GET' : 'POST')
const path = assertGraphPath(params.path)
const url = new URL(GRAPH_API_BASE_URL + path)
appendQuery(url, params.query)
const headers = new Headers(params.headers)
if (!headers.has('accept')) headers.set('accept', 'application/json')
let body: string | undefined
if (params.body !== undefined && params.body !== null) {
if (!headers.has('content-type')) headers.set('content-type', 'application/json')
body = typeof params.body === 'string' ? params.body : JSON.stringify(params.body)
}
const search = params.query?.$search ?? url.searchParams.get('$search')
if (search && !headers.has('consistencylevel')) {
headers.set('ConsistencyLevel', params.consistencyLevel ?? 'eventual')
} else if (params.consistencyLevel) {
headers.set('ConsistencyLevel', params.consistencyLevel)
}
let authFetch: typeof fetch
try {
authFetch = await createAuthenticatedFetch(integrationName)
} catch (error) {
const cause = error instanceof Error ? error.message : String(error)
const reconnect = reconnectUrl(integrationName, inferRequiredScopes(method, path).scopes)
throw new MicrosoftGraphError({
message:
'Could not authenticate Microsoft integration "' +
integrationName +
'". This package uses OAuth (Azure app registration via /connect/oauth), not an API key or bot token. ' +
cause +
' Next step: connect at ' +
reconnect,
status: 401,
code: 'integration_missing',
path,
method,
integrationName,
missingScopes: inferRequiredScopes(method, path).scopes,
reconnectUrl: reconnect,
details: { cause },
})
}
const response = await authFetch(url.toString(), { method, headers, body })
const data = await parseBody(response)
if (response.ok) return data as T
const { code, message } = graphErrorFields(data)
const inferred = inferRequiredScopes(method, path)
const mentioned = scopesMentionedInMessage(message)
const missingScopes = mentioned.length > 0 ? mentioned : inferred.scopes
const reconnect = reconnectUrl(integrationName, missingScopes)
const insufficient =
response.status === 403 ||
code === 'ErrorAccessDenied' ||
code === 'Authorization_RequestDenied' ||
code === 'Forbidden' ||
/insufficient|access denied|forbidden|not have access/i.test(message ?? '')
if (insufficient) {
throw new MicrosoftGraphError({
message: buildInsufficientScopeMessage({
status: response.status,
method,
path,
integrationName,
code,
apiMessage: message,
missingScopes,
product: inferred.product,
setupNote: inferred.setupNote,
reconnect,
}),
status: response.status,
code,
path,
method,
integrationName,
missingScopes,
reconnectUrl: reconnect,
details: data,
})
}
throw new MicrosoftGraphError({
message:
'Microsoft Graph ' +
response.status +
(code ? ' (' + code + ')' : '') +
' on ' +
method +
' ' +
path +
' using integration "' +
integrationName +
'".' +
(message ? ' ' + message : '') +
(response.status === 401
? ' Reconnect the OAuth integration at ' + reconnectUrl(integrationName) + '.'
: ''),
status: response.status,
code,
path,
method,
integrationName,
missingScopes: [],
reconnectUrl: reconnectUrl(integrationName),
details: data,
})
}
/**
* Authenticated Microsoft Graph request for a saved OAuth integration.
* @example
* import graphRequest from 'kody:@kody/microsoft/core'
* const me = await graphRequest({ path: '/me' })
*/
export default graphRequest
export function graphItems<T>(data: unknown): { items: T[]; nextLink: string | null } {
if (!data || typeof data !== 'object') return { items: [], nextLink: null }
const record = data as JsonObject
const value = record.value
const next =
typeof record['@odata.nextLink'] === 'string'
? String(record['@odata.nextLink'])
: null
return {
items: Array.isArray(value) ? (value as T[]) : [],
nextLink: next,
}
}
export function requireConfirmOrDryRun(input: {
dryRun?: boolean
confirm?: boolean
action: string
}): { dryRun: true } | { dryRun: false } {
if (input.dryRun === true) return { dryRun: true }
if (input.confirm === true) return { dryRun: false }
throw new Error(
input.action +
' mutates Microsoft 365 data. Pass dryRun: true to preview, or confirm: true after the user explicitly approved the exact change.',
)
}