All files / services/api apiClient.ts

95.12% Statements 156/164
100% Branches 27/27
100% Functions 4/4
95.12% Lines 156/164

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 1651x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 23x 23x 23x 23x 23x 23x 23x 23x 23x 23x 23x 23x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 360x 360x 360x 360x 360x 1x 1x 1x 1x 1x 1x 1x 336x 336x 10x 10x 336x 336x 335x 337x 5x 5x 5x 2x 2x 5x 335x 337x 3x 3x 3x 3x 3x 3x 3x 3x 332x 337x 3x 3x 3x 3x 3x 3x 3x 3x 329x 337x 1x 1x 1x 1x 1x 1x 1x 1x 1x 328x 337x 14x 14x 14x 14x 14x 14x 14x 314x 314x 314x 1x 338x 338x 338x 338x 338x 338x 338x 338x 338x 338x 338x 338x                 338x 338x 338x 338x 338x 338x 338x 338x 338x 338x 338x 338x 1x 1x 1x 1x 1x 1x 338x 338x 338x 338x 338x 338x  
import {
  getIdToken,
  markNotAuthorized,
  signInSilently,
  signOut,
} from "../auth/authService";
 
declare const WXT_API_BASE_URL: string;
declare const WXT_AZURE_FUNCTION_KEY: string;
 
const TIMEOUT_MS = 30_000;
 
export type ApiErrorCode =
  | "NOT_CONFIGURED"
  | "NETWORK_ERROR"
  | "UNAUTHENTICATED"
  | "NOT_AUTHORIZED"
  | "RATE_LIMITED"
  | "SERVICE_ERROR";
 
/**
 * Typed failure from the storage API. `retryable` drives the UI's
 * error-banner-with-Retry contract (FR-015 / Constitution III).
 */
export class ApiError extends Error {
  readonly status: number;
  readonly code: ApiErrorCode;
  readonly retryable: boolean;
 
  constructor(status: number, code: ApiErrorCode, message: string, retryable: boolean) {
    super(message);
    this.name = "ApiError";
    this.status = status;
    this.code = code;
    this.retryable = retryable;
  }
}
 
export interface ApiRequestInit {
  method?: string;
  body?: unknown;
}
 
/**
 * Authenticated fetch against the storage API: base URL + function key +
 * Bearer ID token. Auth statuses are handled here once — 401 gets one silent
 * renewal then ends the session (sign-in gate); 403 flags the invitation
 * state; 5xx and network failures throw retryable errors. Domain statuses
 * (400/404/409) are returned to the caller.
 */
export async function apiFetch(
  path: string,
  init: ApiRequestInit = {}
): Promise<Response> {
  if (!WXT_API_BASE_URL) {
    throw new ApiError(
      0,
      "NOT_CONFIGURED",
      "The storage service is not configured. Please reinstall the extension.",
      false
    );
  }
  const url = new URL(`${WXT_API_BASE_URL.replace(/\/+$/, "")}${path}`);
  if (WXT_AZURE_FUNCTION_KEY) {
    url.searchParams.set("code", WXT_AZURE_FUNCTION_KEY);
  }
 
  let response = await attempt(url, init, await getIdToken());
 
  if (response.status === 401) {
    // One silent renewal, then the sign-in gate (auth contract).
    const renewed = await signInSilently();
    if (renewed) {
      response = await attempt(url, init, renewed.idToken);
    }
  }
 
  if (response.status === 401) {
    await signOut();
    throw new ApiError(
      401,
      "UNAUTHENTICATED",
      "Your session ended. Sign in to continue.",
      false
    );
  }
 
  if (response.status === 403) {
    await markNotAuthorized();
    throw new ApiError(
      403,
      "NOT_AUTHORIZED",
      "Your account can't sign in right now. See the sign-in screen for details.",
      false
    );
  }
 
  if (response.status === 429) {
    // Only the per-IP rate limiter reaches this generic client (analyze-job's
    // USAGE_LIMIT_REACHED 429 is handled by jobAnalysisClient, contracts/metering.md).
    throw new ApiError(
      429,
      "RATE_LIMITED",
      "Too many requests. Please wait a moment and try again.",
      true
    );
  }
 
  if (response.status >= 500) {
    throw new ApiError(
      response.status,
      "SERVICE_ERROR",
      "The storage service encountered an error. Try again.",
      true
    );
  }
 
  return response;
}
 
async function attempt(
  url: URL,
  init: ApiRequestInit,
  idToken: string | null
): Promise<Response> {
  // Timeout via Promise.race, not AbortSignal: an extension-page
  // AbortController is realm-bound, and repository tests run real fetch
  // (msw/undici) in jsdom where the two realms' AbortSignal brands differ.
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
  const timeout = new Promise<never>((_, reject) => {
    timeoutId = setTimeout(
      () =>
        reject(
          new ApiError(
            0,
            "NETWORK_ERROR",
            "The storage service timed out. Try again.",
            true
          )
        ),
      TIMEOUT_MS
    );
  });
  try {
    const request = fetch(url.toString(), {
      method: init.method ?? "GET",
      headers: {
        "Content-Type": "application/json",
        ...(idToken ? { Authorization: `Bearer ${idToken}` } : {}),
      },
      ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
    }).catch(() => {
      throw new ApiError(
        0,
        "NETWORK_ERROR",
        "Could not reach the storage service. Check your connection and try again.",
        true
      );
    });
    return await Promise.race([request, timeout]);
  } finally {
    clearTimeout(timeoutId);
  }
}