/**
* Microsoft Graph errors → messages a person can act on.
*
* Graph is unusually good at returning precise error codes and unusually bad
* at explaining what to do about them. The mapping below turns the common ones
* into the next step ("consent to X", "run teams_login", "ask an admin").
*/
/** An error returned by the Graph API, with its code preserved. */
export class GraphError extends Error {
readonly status: number;
readonly code: string;
readonly requestId?: string;
readonly raw?: unknown;
constructor(status: number, code: string, message: string, requestId?: string, raw?: unknown) {
super(message);
this.name = "GraphError";
this.status = status;
this.code = code;
this.requestId = requestId;
this.raw = raw;
}
}
/**
* Raised when something asks us to send the access token somewhere it must not go.
*
* Message content is attacker-controlled — a file attachment's `contentUrl` and
* an `
` in the body are both written by whoever sent the message — so a
* download URL that is not the tenant's own Graph endpoint is refused rather
* than fetched with an `Authorization` header.
*/
export class UntrustedHostError extends Error {
readonly url: string;
readonly expectedOrigin: string;
constructor(url: string, expectedOrigin: string) {
let host = url;
try {
host = new URL(url).host;
} catch {
/* keep the raw string */
}
super(
`Refusing to send your Microsoft Graph token to "${host}". ` +
`Only ${expectedOrigin} is trusted with it.`,
);
this.name = "UntrustedHostError";
this.url = url;
this.expectedOrigin = expectedOrigin;
}
}
/** Raised when a scope rule blocks an operation. */
export class ScopeDeniedError extends Error {
readonly target: string;
readonly reason: string;
constructor(target: string, reason: string) {
super(`Blocked by configuration: ${target} — ${reason}.`);
this.name = "ScopeDeniedError";
this.target = target;
this.reason = reason;
}
}
const CONSENT_HINTS: Record = {
"ChannelMessage.Read.All": "reading channel messages",
"ChannelMessage.Send": "posting in channels",
"Chat.ReadWrite": "reading and sending chat messages",
"ChatMessage.Send": "sending chat messages",
"ChatMember.ReadWrite": "removing someone from a chat",
"Presence.ReadWrite": "reading and setting your presence",
"Calendars.ReadWrite": "reading and creating calendar events",
"OnlineMeetings.ReadWrite": "creating Teams meetings",
};
/** Normalize any thrown value into a readable, actionable message. */
export function formatGraphError(error: unknown): string {
if (error instanceof ScopeDeniedError) return error.message;
if (error instanceof UntrustedHostError) return error.message;
if (error instanceof GraphError) {
switch (error.code) {
case "InvalidAuthenticationToken":
return "The access token was rejected. Run teams_login to sign in again.";
case "Unauthorized":
return "Authentication failed. Run teams_login to sign in again.";
case "Forbidden":
case "AccessDenied":
case "Authorization_RequestDenied":
return (
"Permission denied. The signed-in user (or the app registration) lacks access to this " +
"resource. Run teams_doctor to see which Graph scopes were actually granted."
);
case "NotFound":
case "itemNotFound":
case "ResourceNotFound":
return "Not found. Check the team, channel, chat or message ID.";
case "ErrorItemNotFound":
return "Not found — the item may have been deleted or you no longer have access.";
case "activityLimitReached":
case "TooManyRequests":
return "Throttled by Microsoft Graph. Wait a moment and try again.";
case "BadRequest":
return `Graph rejected the request: ${error.message}`;
case "UnknownError":
return `Graph returned an unspecified error: ${error.message}`;
default:
return `${error.code}: ${error.message}`;
}
}
if (error instanceof Error) {
const msg = error.message;
if (msg.includes("ENOTFOUND")) return "DNS error: could not resolve the Graph endpoint.";
if (msg.includes("ECONNREFUSED") || msg.includes("ETIMEDOUT")) {
return "Network error: could not reach Microsoft Graph. Check your connection or proxy.";
}
if (msg.includes("AADSTS7000218")) {
return (
"The app registration rejected the device code flow. Enable 'Allow public client flows' " +
"in Entra ID → App registrations → Authentication."
);
}
if (msg.includes("AADSTS65001")) {
return "Consent is missing for one or more scopes. Sign in again, or ask an admin to grant consent.";
}
if (msg.includes("AADSTS50076") || msg.includes("AADSTS50079")) {
return "Multi-factor authentication is required. Complete the sign-in in the browser and retry.";
}
if (msg.includes("AADSTS50020")) {
return (
"The signed-in user does not exist in that tenant. For a guest/customer tenant, make sure the " +
"account is invited there and the app registration is multi-tenant."
);
}
return msg.split("\n")[0] ?? "Unknown error";
}
return String(error);
}
export function isAuthError(error: unknown): boolean {
if (error instanceof GraphError) {
return error.status === 401 || error.code === "InvalidAuthenticationToken";
}
return false;
}
export function isNotFoundError(error: unknown): boolean {
if (error instanceof GraphError) return error.status === 404;
return false;
}
export function isThrottled(error: unknown): boolean {
if (error instanceof GraphError) return error.status === 429 || error.status === 503;
return false;
}
/** Human-readable purpose of a Graph scope, for doctor output. */
export function describeScope(scope: string): string | undefined {
return CONSENT_HINTS[scope];
}