Network Refresh — Technical Reference

This document describes the internal mechanics of the Network Refresh feature: how requests are routed, which IC REST API endpoints are called, how the JSON response is parsed into the extension's internal data format, and what each layer of code is responsible for.

For the user-facing feature overview (pre-flight dialog, progress dialog, fallback behavior), see features/network-refresh.md.


Contents

  1. High-Level Architecture
  2. Request Flow Diagram
  3. IC REST API Endpoints
  4. Session Validation
  5. The Three-Step Grade Fetch
  6. API Base URL Construction
  7. Why Requests Route Through the Service Worker
  8. Background Service Worker Handler
  9. ICApiClient Module
  10. Response Parsing (parseApiResponse)
  11. Assignment Mapping (_mapAssignments)
  12. Category Average Calculation (_calcCategoryAvg)
  13. Overall Grade Derivation (_calcOverall)
  14. RefreshController Integration
  15. Storage Write After Refresh
  16. Error Handling and Fallback
  17. Security Constraints

High-Level Architecture

Network Refresh is implemented across three files:

File Responsibility
ic-api-client.js IC REST API session check, 3-step grade fetch, JSON → internal format parsing
background.js Service worker message handler - executes the actual fetch() with credentials
refresh-controller.js Pre-flight dialog, progress dialog, orchestrates per-course refresh loop

The extension page (dashboard.html) cannot make credentialed cross-origin requests directly. All IC API calls are therefore routed through the background service worker using chrome.runtime.sendMessage.


Request Flow Diagram

dashboard.html (refresh-controller.js)
  │
  ├─ calls: ICApiClient.validateSession(apiBase)
  │           └─ ICApiClient._fetchViaServiceWorker(url)
  │                 └─ chrome.runtime.sendMessage({ type: 'ICI_IC_FETCH', url })
  │                       └─ background.js ICI_IC_FETCH handler
  │                             └─ fetch(url, { credentials: 'include', ... })
  │                                   └─ IC server → JSON (or error)
  │
  └─ calls: ICApiClient.fetchCourseGrades(apiBase, sectionID)
              ├─ Step 1: fetch section data (term IDs)
              ├─ Step 2: fetch grading task list (task ID)
              └─ Step 3: fetch grade detail (all quarters)
                    └─ ICApiClient.parseApiResponse(data, ...)
                          └─ writes to chrome.storage.local

IC REST API Endpoints

All endpoints are relative to the API base (see API Base URL Construction).

Session Check

GET {base}/resources/my/userAccount

Returns:

{ "personID": 12345, "firstName": "Jane", "lastName": "Smith" }

Returns HTTP 404 (not 401/403) when the session cookie is absent. Returns HTML (a login page redirect) instead of JSON when the district enforces a redirect-based login.


Step 1 - Section Metadata

GET {base}/resources/section/{sectionID}
    ?_expand=terms
    &_expand=periods-periodSchedule
    &_expand=room
    &_expand=teachers

The terms array is the key output. Each term object includes:

Field Type Meaning
termID number Primary key used in Step 3
termName string Human-readable label, e.g. "Q1"
termSeq number Ordering index (ascending = chronological)
startDate string (ISO) First day of the term
endDate string (ISO) Last day of the term
isPrimary boolean false for non-graded scheduling terms

Term selection logic: Terms are filtered to isPrimary !== false, then sorted by termSeq. The latest term whose startDate is on or before today is selected as targetTerm. This matches the term that IC's own portal selects when you open a course page. If all terms are future-dated (e.g. the school year hasn't started), the first sorted term is used as a fallback.


Step 2 - Grading Task List

GET {base}/resources/section/teacherSections/curriculumGradingTask
    ?_sectionID={sectionID}

Returns an array of grading task objects. The extension picks the task where portal: true and whose name does not match /standard|comment/i. In practice this is always the "Quarter Grade" task with _id: "4".

This step is non-fatal - if the request fails or no matching task is found, taskID is omitted from Step 3's URL and IC returns data using its default task.


Step 3 - Grade Detail

GET {base}/resources/portal/grades/detail/{sectionID}
    ?classroomSectionID={sectionID}
    &selectedTermID={targetTerm.termID}
    &selectedTaskID={taskID}

This is the primary data endpoint. When selectedTermID is the current (active) term, IC returns grades for all terms up to and including the selected one - not just the selected term. This means a single Step 3 call gives the full year's data.

The response shape:

{
  "courseName": "HONORS ALGEBRA 2 & TRIG 434",
  "terms": [
    { "termID": 4461, "termName": "Q1", "termSeq": 1, "startDate": "2024-09-05", "endDate": "2024-11-08" },
    { "termID": 4463, "termName": "Q2", "termSeq": 2, "startDate": "2024-11-12", "endDate": "2025-01-17" }
  ],
  "details": [
    {
      "task": {
        "termID": 4461,
        "termName": "Q1",
        "portal": true,
        "hasAssignments": true,
        "hasDetail": true
      },
      "categories": [
        {
          "groupID": "c0",
          "name": "Tests/Quizzes",
          "weight": 60,
          "isWeighted": true,
          "assignments": [ ... ]
        }
      ]
    }
  ]
}

Session Validation

ICApiClient.validateSession(apiBase) calls the /resources/my/userAccount endpoint and returns:

// Success
{ valid: true, personID: 12345, firstName: "Jane", lastName: "Smith" }

// Not signed in (HTTP 404 or not_json redirect)
{ valid: false, reason: "unauthenticated" }

// Login redirect (HTML response from _fetchDirect)
{ valid: false, reason: "redirected_to_login" }

// Network error or unknown failure
{ valid: false, reason: "<error message>" }

The pre-flight dialog uses this result to show a session badge. If valid is false with reason unauthenticated or redirected_to_login, both refresh buttons are visually disabled (opacity 0.5, pointer-events none). An "I know what I'm doing" override checkbox re-enables them for developers or power users who want to test refresh behavior without an active session.


The Three-Step Grade Fetch

ICApiClient.fetchCourseGrades(apiBase, sectionID) executes the three steps sequentially:

// Step 1
const sectionData = await doFetch(`${apiBase}/resources/section/${sectionID}?_expand=terms...`);

// Step 2 (non-fatal)
let taskID = null;
try {
  const tasks = await doFetch(`${apiBase}/resources/section/teacherSections/curriculumGradingTask?_sectionID=${sectionID}`);
  const primary = tasks.find(t => t.portal && !/standard|comment/i.test(t.name));
  if (primary) taskID = primary._id;
} catch (_) { /* non-fatal */ }

// Step 3
let detailUrl = `${apiBase}/resources/portal/grades/detail/${sectionID}?classroomSectionID=${sectionID}`;
if (targetTerm) detailUrl += `&selectedTermID=${targetTerm.termID}`;
if (taskID)     detailUrl += `&selectedTaskID=${taskID}`;
const gradeData = await doFetch(detailUrl);

The function returns the raw Step 3 response (enriched with courseName from Step 1 if absent).


API Base URL Construction

ICApiClient.buildApiBase(sourceUrl) extracts the IC district origin and servlet context path from a cached sourceUrl:

Input:  https://scarsdaleny.infinitecampus.org/campus/nav-wrapper/student/…
Output: https://scarsdaleny.infinitecampus.org/campus

Implementation:

function buildApiBase(sourceUrl) {
  const url = new URL(sourceUrl);
  const firstSegment = url.pathname.split('/').filter(Boolean)[0];
  const appContext = firstSegment ? `/${firstSegment}` : '/campus';
  return `${url.origin}${appContext}`;
}

The first non-empty path segment is the IC application context (almost always /campus). This is reliable because the sourceUrl stored in the cache always comes from an actual IC course page URL, which always starts with the servlet context path.


Why Requests Route Through the Service Worker

Extension pages (chrome-extension:// origin) cannot make credentialed fetch() calls to external origins directly without CORS cooperation. However, Chrome's Manifest V3 service workers with host_permissions are granted a special privilege:

"A script executing in an extension service worker can talk to remote servers outside of its origin, as long as the extension requests host_permissions."

Additionally, SameSite=Lax cookies are bypassed for host_permissions URLs when the request originates from the service worker, so the IC session cookie (which uses SameSite=Lax) is included automatically. This is not the case for extension page fetch() calls, which are treated like cross-site requests by browsers.

The result: service worker fetch(url, { credentials: 'include' }) with host_permissions = full authenticated API access, no tab needed.


Background Service Worker Handler

background.js listens for ICI_IC_FETCH messages:

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.type !== 'ICI_IC_FETCH') return;

  // Security: reject any URL that is not on the IC domain
  const h = new URL(msg.url).hostname;
  if (!h.endsWith('.infinitecampus.org') && h !== 'infinitecampus.org') {
    sendResponse({ err: 'blocked_url' });
    return true;
  }

  fetch(msg.url, {
    credentials: 'include',
    headers: {
      'Accept': 'application/json, text/plain, */*',
      'X-Requested-With': 'XMLHttpRequest'
    }
  })
    .then(resp => {
      if (!resp.ok) { sendResponse({ err: `HTTP_${resp.status}` }); return; }
      const ct = resp.headers.get('content-type') || '';
      if (!ct.includes('application/json')) {
        sendResponse({ err: `not_json:${ct.slice(0, 80)}` }); return;
      }
      return resp.json().then(data => sendResponse({ ok: data }));
    })
    .catch(e => sendResponse({ err: e.message || 'fetch_failed' }));

  return true; // keep message channel open for async response
});

Key points:

  • Domain guard: Only *.infinitecampus.org and infinitecampus.org hostnames are allowed. Any other URL is rejected with err: 'blocked_url'.
  • X-Requested-With header: IC checks this header to distinguish AJAX requests from browser page navigations. Without it, some endpoints return a full HTML page rather than JSON.
  • return true: Required in Chrome MV3 message handlers to keep the response channel open for async callbacks.
  • Non-JSON detection: If IC returns HTML (e.g. a session expiry redirect page), the handler detects the non-application/json content type and returns a descriptive error string rather than failing on JSON.parse.

ICApiClient Module

ic-api-client.js exposes four public functions:

Function Signature Description
buildApiBase (sourceUrl: string) → string|null Extracts {origin}/{appContext} from a course URL
validateSession (apiBase: string) → Promise<SessionInfo> Checks if the user is signed in via /resources/my/userAccount
fetchCourseGrades (apiBase: string, sectionID: string) → Promise<RawApiData> Executes the 3-step fetch and returns the raw Step 3 response
parseApiResponse (apiData, courseName, sectionID, sourceUrl) → CacheEntry Converts the raw API response into the internal cache entry format

The module detects whether it is running in an extension page via:

function _isExtensionPage() {
  return typeof window !== 'undefined' &&
         !!window.location &&
         window.location.protocol === 'chrome-extension:';
}
  • Extension page (chrome-extension://): uses _fetchViaServiceWorker - routes through ICI_IC_FETCH.
  • Content script or Node (tests): uses _fetchDirect - calls fetch() directly.

Response Parsing (parseApiResponse)

parseApiResponse(apiData, courseName, sectionID, sourceUrl) converts the Step 3 JSON into the CacheEntry structure expected by dashboard.js and details.js.

Input: Raw Step 3 response object (with terms[] and details[]).

Output:

{
  snapshotTs: <epoch ms>,
  snapshot: {
    courseName: "HONORS ALGEBRA 2 & TRIG 434",
    overall: 94.32,          // derived from the most recent non-zero quarter
    quarters: [
      {
        id: "Q1",
        name: "Q1",
        startDate: "2024-09-05",
        endDate: "2024-11-08",
        categories: [
          {
            id: "c0",
            name: "Tests/Quizzes",
            weight: 60,
            avg: 91.5,
            assignments: [ ... ]
          }
        ]
      }
    ]
  },
  raw: { courseId: "course:897283", courseName: "...", quarters: [] },
  sourceUrl: "https://...",
  source: "network"
}

Parsing logic:

  1. Build a termMap from apiData.terms[]: { [termID]: { termName, startDate, endDate, termSeq } }.
  2. Iterate apiData.details[]. Skip entries where task.portal is falsy or both task.hasAssignments and task.hasDetail are falsy.
  3. Group by termID into a quarterMap. For each detail entry, call _mapAssignments on each category's assignment list and _calcCategoryAvg to compute the category average.
  4. Sort quarters by _termSeq ascending, then strip the _termSeq field.
  5. Call _calcOverall(quarters) to derive the overall grade.

Assignment Mapping (_mapAssignments)

Each raw IC assignment is mapped to the extension's normalized assignment shape:

IC API field Extension field Notes
assignmentName name -
scorePoints earned null when ungraded; parsed as float
totalPoints possible parsed as float
score (derived) Used to detect letter grades (`/^([A-D][+-]?
notGraded ungraded: true Also set when score === null and not a letter grade and not dropped
dropped: true dropped: true -
multiplier multiplier Only set when !== 1
letter-grade score letterGrade: "B+" Set when score is a letter and scorePoints is null

Ungraded detection rule:

const ungraded = !!(
  a.notGraded ||
  (!isLetterGrade && !a.dropped && rawScore === null)
);

IC uses scorePoints: "0" for some truly ungraded assignments (where no teacher input has been given yet) while setting score: null. The extension must check score (not scorePoints) to distinguish "the score is 0 points" from "not yet graded". Assignments with ungraded: true are excluded from all grade calculations and shown as in the UI.


Category Average Calculation (_calcCategoryAvg)

function _calcCategoryAvg(assignments) {
  const graded = assignments.filter(a => !a.ungraded && !a.dropped);
  if (graded.length === 0) return 0;
  let totalEarned = 0, totalPossible = 0;
  for (const a of graded) {
    const m = typeof a.multiplier === 'number' ? a.multiplier : 1;
    totalEarned   += Math.max(0, Number(a.earned))   * m;
    totalPossible += Math.max(0, Number(a.possible)) * m;
  }
  return totalPossible > 0 ? (totalEarned / totalPossible) * 100 : 0;
}
  • Dropped assignments are excluded.
  • Ungraded assignments are excluded.
  • Multipliers scale both numerator and denominator.
  • Returns 0 (not NaN) when no eligible assignments exist.

Note: this function is used only during the network-refresh parse. The grade display on the dashboard and details pages re-calculates category averages using their own helpers (recomputeCategoryWithSebi in dashboard.js, catStats in details.js), which additionally handle letter grade resolution and SEBI mode.


Overall Grade Derivation (_calcOverall)

function _calcOverall(quarters) {
  for (let i = quarters.length - 1; i >= 0; i--) {
    const q = quarters[i];
    const weighted   = q.categories.filter(c => c.weight > 0 && c.avg > 0);
    const unweighted = q.categories.filter(c => (!c.weight || c.weight === 0) && c.avg > 0);
    let overall = 0;
    if (weighted.length > 0) {
      const weightSum = weighted.reduce((s, c) => s + c.weight, 0);
      overall = weightSum > 0 ? weighted.reduce((s, c) => s + c.avg * c.weight, 0) / weightSum : 0;
    } else if (unweighted.length > 0) {
      // Points-based: aggregate all assignments across categories
      let totalEarned = 0, totalPossible = 0;
      for (const c of unweighted)
        for (const a of c.assignments.filter(a2 => !a2.ungraded && !a2.dropped)) {
          totalEarned   += Math.max(0, Number(a.earned));
          totalPossible += Math.max(0, Number(a.possible));
        }
      overall = totalPossible > 0 ? (totalEarned / totalPossible) * 100 : 0;
    }
    if (overall > 0) return overall;
  }
  return 0;
}

Iterates quarters in reverse order (most recent first) and returns the first non-zero overall grade. This mimics the "current quarter wins" behavior of the IC portal's grade summary page.


RefreshController Integration

refresh-controller.js calls network refresh inside networkRefreshCourse(courseKey, courseName, entry):

1. Extract sectionID from courseKey ("course:897283" → "897283")
2. buildApiBase(entry.sourceUrl) → apiBase
3. ICApiClient.fetchCourseGrades(apiBase, sectionID) → apiData
4. ICApiClient.parseApiResponse(apiData, ...) → newEntry
5. Guard: if newEntry.snapshot.overall === 0 and existing overall > 0, skip write (preserve existing)
6. Backup: chrome.storage.local.set({ [`${courseKey}_BACKUP`]: entry })
7. Write: chrome.storage.local.set({ [courseKey]: newEntry, ICI_CACHE_INDEX: index })

The zero-grade guard (step 5) prevents a network refresh that returns empty data from wiping out a valid cached grade. This can happen during school hours when some districts temporarily disable the gradebook API.


Storage Write After Refresh

After a successful network refresh, two keys are updated:

Key Value
course:{sectionID} Full CacheEntry (see parseApiResponse output above)
ICI_CACHE_INDEX Updated index entry: { courseId, courseName, lastUpdated, sourceUrl }

A backup of the previous entry is also written to course:{sectionID}_BACKUP. This backup is not currently exposed in the UI but is available for manual recovery via the import/export mechanism.

Permanent score overrides (PE_course:{sectionID}) and permanent assignment additions (PA_course:{sectionID}) are not modified by network refresh. They continue to be applied on top of the refreshed grade data the next time the course is viewed.


Error Handling and Fallback

All errors from fetchCourseGrades (non-session errors) cause the refresh loop in RefreshController to fall back to the Classic scrape-based strategy for that individual course. The fallback is logged as scrape-fallback in the progress dialog.

Session errors (those whose Error.message starts with session:) are not retried or fallen back - if the session is invalid, Classic refresh would also fail, so the error is surfaced directly.

Error message prefix  │ Behavior
───────────────────────┼─────────────────────────────────────
"session:*"           │ Propagate; do not fall back to scrape
"Canceled by user"    │ Abort immediately; show canceled state
HTTP_4xx / HTTP_5xx   │ Fall back to Classic scrape
not_json:*            │ Fall back to Classic scrape
sw_error:* / sw_no_*  │ Fall back to Classic scrape

Security Constraints

  • Domain restriction: background.js rejects any URL whose hostname is not infinitecampus.org and does not end in .infinitecampus.org. This prevents the ICI_IC_FETCH message handler from being misused as a general-purpose fetch proxy by any page that can send messages to the extension.
  • No user credentials stored: The extension never stores or transmits IC passwords or session tokens. It relies entirely on the browser's existing session cookie, which Chrome provides automatically when the service worker fetches a host_permissions URL with credentials: 'include'.
  • Host permissions scope: host_permissions is limited to https://*.infinitecampus.org/* and https://infinitecampus.org/*. The extension cannot make credentialed requests to any other domain through the service worker.
  • Read-only API access: All IC API calls are GET requests. The extension never writes to the IC gradebook.