Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/microsoft

src/core.ts

318 lines · 9.6 KB · TypeScript
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.',
  )
}