Skip to content

Spec 10 data and api

Claude edited this page Oct 8, 2026 · 1 revision

10. Data layer, networking, caching, offline, persistence, and the ccc-server API contract

← 09. Streaming media: radio, webcams, schedules, movie · Spec index

The app reads almost everything over HTTPS. Most requests go to ccc-server, a Koa proxy and cache. St. Olaf's deployment is at https://stolaf.frogpond.tech/v1/; developers can override that address. Carleton's deployment is at https://carleton.frogpond.tech/v1/ and cannot be overridden. A published "sources manifest" (a JRD document from GET sources, with a copy bundled in the build) tells the app where feature data lives. Each source is either relative, meaning a ccc-server proxy, or absolute, meaning the app calls the third party directly. React Query caches responses in memory with a 24-hour gcTime. Successful queries are saved to AsyncStorage as one JSON blob, and a blob written by any other build is thrown away. Online status comes from NetInfo, and the app counts as focused while it is in the foreground. Some data does not live in the query cache. Calendar events and the course catalog live in SQLite (aao.db plus an attached course-catalog.db in Caches). Settings live in redux-persist (persist:root), eleven Zustand persisted stores, two prefixed AsyncStorage keys, an expo-sqlite key-value store (telemetry consent), the Keychain (SIS credentials) and UserDefaults (DebugSwift switch). "Reset Everything" and the UI-test reset wipe most of this; the exceptions are listed under Persisted state. This chapter documents the shared machinery and the full on-device inventory. It also catalogues every ccc-server endpoint for both institutions, with its upstream, server cache TTL and HTTP caching behaviour, and marks which ones the app calls.

Source map

App (/home/claude/AAO-React-Native):

Server (/home/claude/ccc-server):

Screens

This area owns no end-user screen. It owns the shared states that every data screen draws and the developer screens that change networking.

Shared state views (used by every query-backed screen)

  • Loading: LoadingView (modules/notice/loading.tsx).

  • Error with nothing saved: LoadErrorView. Title Couldn’t Load by default, the SF Symbol exclamationmark.triangle, and a description from describeError(error). That description is the error's message, or Something went wrong. when there is none. A bordered, large Try Again button triggers the retry (modules/notice/notice.tsx:70-99). The user therefore sees raw messages such as News fetch failed: 500 (a SourceFetchError) or ky's HTTPError text.

  • Offline with nothing saved: decided by listState() (modules/notice/list-state.ts:12-26), checked in order:

    1. Any saved data gives list; data always wins.
    2. Otherwise an error gives error.
    3. Otherwise a query that is paused while NetInfo says offline gives offline.
    4. Otherwise a pending query gives loading.
    5. Anything else gives list.

    A pause while NetInfo reports online stays loading, because React Query also pauses for an inactive app. Screens draw offline as a NoticeView with title Offline, symbol wifi.slash and a feature-specific description. Known copy:

  • There is no app-wide offline banner. NetInfo is used only to drive React Query's onlineManager (source/init/tanstack-query.ts:155-159). Messenger screens show PageLoading paused when fetchStatus === 'paused'; see the Messenger chapter.

Developer → Server URL section (source/features/developer/server-url.tsx)

  • How it is reached: the Developer screen (app/developer/index.tsx, title Developer, identifier screen-developer). It opens from Home's Developer tile in dev mode, which is a debug build or the persisted devModeOverride.
  • Layout: a form section titled Server URL with footer Empty means we will use the default URL.
    • While the stored value loads, the text field is disabled and shows placeholder Loading….
    • Once loaded, the text field shows the stored address, with placeholder https://stolaf.frogpond.tech/v1/ and keyboard submit label done. Under it is an action row titled Save. That row reads Invalid URL! and is disabled when the text is non-empty and not an http:/https: URL.
    • The field and button are disabled while saving.
  • Saving: submitting the field or pressing Save stores the address, appending a trailing / if it is missing (source/lib/storage.ts:57). The JS app then restarts (Restart()). An empty value is stored as '', which means the default is used.
  • Local Servers section (Local Servers, footer Tap a server to use it.): shown only in dev mode, only when the RNZeroconf native module is linked, and only once at least one service has been found. The app browses Bonjour type _ccc-server._tcp in local.. Each found service becomes the URL http://<first address or host without trailing dot>:<port><txt.path or '/v1/'>; an IPv6 address is bracketed. Tapping a row fills the field without saving. A service that goes away removes its row (use-server-discovery.ts:19-67).

Developer → Reset Everything (source/features/developer/developer.tsx:12-21,57)

  • An action row Reset Everything opens an alert titled Reset Everything, with message Are you sure you want to clear everything? and buttons Nope! (cancel) and Reset it! (destructive).
  • Confirming runs refreshApp(); see Behaviors.

Developer → API Tester (app/developer/api-test/index.tsx, detail.tsx)

  • List: title API Tester. It lists GET routes grouped by first path segment, with pull-to-refresh, and has a bottom search bar with placeholder /path/to/uri.
  • States: loading, LoadErrorView, or No Routes.
  • Detail: tapping a route, or searching, opens the detail screen. It fetches client.get(<displayName trimmed and lowercased>) as text, with staleTime: 0 and gcTime: 0, and shows it raw or as syntax-highlighted JSON.

Developer → Bon Appetit Picker (app/developer/bon-app-picker.tsx)

  • Title Dev BonApp Picker, with a numeric id field.
  • With nothing entered it shows No Café ID / Enter a café’s ID to see its menu. (symbol fork.knife).
  • An entered id loads the BonApp menu for {id}, which uses food/menu/:id?cafeId=:id and food/cafe/:id?cafeId=:id.

Data

Base URLs, clients, request behaviour

  • St. Olaf base: DEFAULT_URL = 'https://stolaf.frogpond.tech/v1/' (source/lib/constants.ts:3).
    • At launch, source/init/api.ts:11-21 reads aao::settings:server-address and uses it if it is non-empty, else the default. It then creates client = ky.create({baseUrl}) (modules/api/index.ts:16-19).
    • The read is async. Until it finishes, client is undefined, so any request through client before then would throw. apiUrl() and imageUrl() fall back to DEFAULT_URL while the root is unset (source/lib/api-url.ts:13-15, remote-images.ts:30-33).
  • Carleton base: CARLETON_DEFAULT_URL = 'https://carleton.frogpond.tech/v1/' (constants.ts:6). It is set synchronously before the St. Olaf read and is never overridable (source/init/api.ts:8). It is used only for spaces/hours and map/geojson (source/features/building-hours/query.ts:31-33, source/features/map/query.ts:22-24).
  • Relative paths: always without a leading slash, resolved against a base that ends in /.
  • Headers:
    • The app sets no User-Agent, no Accept and no auth headers on client (default iOS/React Native fetch UA, inferred).
    • Sentry adds sentry-trace/baggage to requests whose URL matches localhost, frogpond.tech or ^/ (source/init/sentry.ts:41) when reporting is enabled.
    • The course catalog request adds If-None-Match (source/database/courses/refresh.ts:84-86).
    • PaperCut requests send Content-Type: application/x-www-form-urlencoded and Origin: https://papercut.stolaf.edu (source/lib/stoprint/urls.ts:9-15).
  • Timeouts:
    • Requests through client use ky's default 10 s per attempt (modules/data-sources/fetch-source.ts:80-86, comment).
    • Absolute-href sources fetched by fetchSourceBody get a manual 10 000 ms abort timer that is also tied to the caller's AbortSignal (fetch-source.ts:3,26-41).
    • Plain ky.get calls (directory search, OleCard, PaperCut) use ky's defaults (inferred: 10 s timeout and ky's retry policy).
  • Retries:
    • React Query defaults apply: 3 retries with exponential backoff, networkMode: 'online' (library default, inferred, @tanstack/react-query 5.104.0).
    • ky's own retry runs beneath React Query for client and ky calls (inferred ky 2.1.0 default: 2 retries for GET on 408/413/429/500/502/503/504).
    • Overrides:
      • manifest: retry: false, networkMode: 'offlineFirst'
      • org detail: retry: false
      • course catalog: shouldRetryCatalog, at most 3 and never for a rejected file
      • map categories: no retry for UnreadableMapCategoriesError, else at most 3
      • Messenger story: no retry on a missing story
      • Messenger playlist page: retry: 1
  • Error mapping:
    • fetchSourceBody turns any error status, for a relative or an absolute href, into SourceFetchError(message = "<label> fetch failed: <status>", status, code). code is a WordPress error body's code, read only when the content type contains json (fetch-source.ts:47-124).
    • Other errors (ky HTTPError, TimeoutError, network TypeError) pass through unchanged.
  • parseEach (modules/data-sources/parse-each.ts): items that fail their schema, or whose conversion throws, are dropped. A non-empty response in which every item failed throws every <what> was malformed. An empty response stays empty.

React Query defaults and persistence (source/init/tanstack-query.ts)

  • QueryClient: gcTime is 24 h for all queries (:20-28). Other defaults are the library's: staleTime 0, refetch on mount, focus and reconnect when stale (inferred).
  • Focus: an AppState change to active marks the app focused, and any other state unfocused (:162-166). Stale active queries therefore refetch when the app returns to the foreground (inferred library behaviour).
  • Online: a NetInfo listener sets online to Boolean(state.isConnected) (:155-159). Reachability (isInternetReachable) is ignored. Reconnecting resumes paused fetches and refetches stale queries (inferred).
  • Persister: createAsyncStoragePersister({storage: AsyncStorage, serialize: serializeCache}) (:90-93). It writes the whole cache as one AsyncStorage value under the library default key REACT_QUERY_OFFLINE_CACHE, throttled to 1 s (library defaults, inferred).
  • Restore: PersistQueryClientProvider in app/_layout.tsx:112, inside redux's PersistGate, which shows Loading App... until redux rehydrates (:111). No maxAge is given, so the library default of 24 h applies (inferred): a saved cache older than 24 h is discarded on restore.
  • Buster: `${nativeApplicationVersion}+${nativeBuildVersion}` (:121). A cache saved by any other build, or with no buster, is dropped (source/init/__tests__/tanstack-query.test.ts:204-212).
  • What is dehydrated (shouldDehydrateQuery, :139-146), checked in this order:
    1. A key whose first element is a string starting with calendar (isCalendarQueryKey, :110-113) is never persisted. This covers ['calendar','named',…] and calendar-db.
    2. meta.persist === false is never persisted.
    3. meta.persistPages set, on infinite data, is persisted.
    4. meta.persistAfterFailure set, with data present, is persisted.
    5. Otherwise the query is persisted only when status === 'success'.
  • Serialization (serializeCache, :75-88):
    • An infinite query with persistPages: N is cut to its first N pages and written as success, with no error.
    • A persistAfterFailure query in error with data is written as success with its old data.
  • Queries opted out (meta.persist: false):
  • Queries with persistPages: 1: Messenger feed, category lists and issues list (mess/query.ts:110,172,188).
  • persistAfterFailure: the sources manifest (modules/data-sources/resolve.ts:32).
  • Pull to refresh on infinite lists: refetchFromFirstPage trims each active infinite query under the key to page 1, then refetches the active queries (source/lib/infinite-data.ts:28-34).

Sources manifest (modules/data-sources)

  • Request: GET {base}sources through client, parsed with JrdSchema.
  • Schema: {subject: string, links: [{rel: URL, href: non-empty URI reference without whitespace or <>"{}|\^\, type: non-empty, titles?: record, properties: {"https://frogpond.tech/ns/id": non-empty}}]} (types.ts:30-45`).
  • Query: key ['data-sources','manifest'], staleTime 24 h, networkMode: 'offlineFirst', retry: false (resolve.ts:16-37).
  • Resolution rules:
    1. fetchManifest returns the bundled copy when the fetch fails or the response is malformed, so it never hangs offline (resolve.ts:41-47).
    2. A missing source falls back to its bundled entry.
    3. A source whose type is unsupported falls back to its bundled entry. If the bundled entry is also unsupported, resolution throws no supported source for rel "<rel>" and id "<id>" (resolve.ts:79-97).
    4. resolveSources lists every id from the fetched and bundled documents and drops any id that cannot resolve (resolve.ts:99-116).
  • useManifest (radio) returns undefined while the persisted cache is restoring. After that it returns the cached manifest however old, or {subject:'', links:[]}, which resolves to the bundled entries (use-manifest.ts:18-25).
  • Bundled and published entries (modules/data-sources/bundled.json, data/sources.yaml); "relative" means served by ccc-server:
rel (suffix) id type href
news stolaf wordpress.v2.posts+json https://wp.stolaf.edu/wp-json/wp/v2/posts?per_page=10&_embed=true (direct)
news mess wordpress.v2.posts+json https://olafmessenger.com/wp-json/wp/v2/posts?per_page=50&_embed=true (direct)
news oleville frogpond.feed-items+json news/named/oleville (relative; no UI uses it)
a-to-z stolaf stolaf.a-z+json https://wp.stolaf.edu/wp-json/site-data/sidebar/a-z (direct)
a-to-z extras frogpond.a-z-extras+json a-to-z/extras
calendar stolaf tribe.events.v1+json https://wp.stolaf.edu/calendar/wp-json/tribe/events/v1/events?per_page=50 (direct)
calendar presence presence.events+json https://api.presence.io/stolaf/v1/events (direct)
calendar krlx-schedule / ksto-schedule frogpond.events+json calendar/named/krlx-schedule, calendar/named/ksto-schedule
jobs stolaf oracle.recruiting-ce+json https://fa-ewur-saasfaprod1.fa.ocs.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1 (direct)
org-categories stolaf frogpond.org-categories+json orgs/category-styles
map-categories stolaf frogpond.map-categories+json map/categories
student-work-areas / student-wages / student-work-units stolaf frogpond.* student-work/areas, student-work/wages, student-work/units
map-style stolaf-light / stolaf-dark maplibre.style+json map/style, map/style-dark
course-catalog stolaf application/vnd.sqlite3 courses/catalog.db
radio-stream ksto / krlx mpegurl / audio/mpeg https://cdn.stobcm.com/ksto/live.m3u8, https://s3.voscast.com:10803/stream (direct)
radio-player-page ksto text/html https://www.stolaf.edu/multimedia/play/embed/ksto.html (direct; only https://www.stolaf.edu is accepted)
radio-now-playing krlx metaradio.stationnow+json https://content.krlx.org/wp-json/metaradio/v1/stationnow/?station=1 (direct)

Complete catalogue of app requests

All queries use React Query unless noted. "Persisted" follows the dehydration rules above.

Feature Query key Request staleTime / refetch Notes
Manifest ['data-sources','manifest'] GET sources 24 h persistAfterFailure
Student orgs ['orgs'] GET orgs 5 min UI-test fixture
Org detail ['org-detail', uri] GET orgs/uri/{encodeURIComponent(uri)}; 404 → null 5 min, no retry query.ts:45-75
Org memberships ['org-category-memberships'] GET orgs/categories 5 min
Org category icons ['org-category-icons'] manifest → orgs/category-styles; body .data 5 min no source → []
Webcams ['streaming','webcams'] GET webcams → .data default
Streams ['streams',{sort,dateFrom,dateTo}] GET streams/upcoming?sort=ascending&dateFrom=YYYY-MM-DD&dateTo=+2 months (campus time zone) default
BonApp menu ['cafe-menu','bonApp',path,day] GET food/named/menu/{stav-hall,the-cage,burton,ldc,sayles,weitz,schulze} or food/menu/{id}?cafeId={id} 1 h day = campus YYYY-MM-DD
BonApp cafe ['cafe-info','bonApp',path,day] GET food/named/cafe/{…} or food/cafe/{id}?cafeId={id} 1 h
Pause menu ['cafe-menu','hosted','food/named/menu/the-pause'] GET food/named/menu/the-pause → .data default
Athletics ['athletics','scores'] GET athletics/scores refetchInterval 30 s if any game is in play, else 5 min
Building hours [campus,'buildings'] GET spaces/hours (Carleton client for carleton) → .data default; 5 min from map cards St. Olaf bundled under UI test or dev override
Map geojson [campus,'map','geojson'] GET map/geojson (per-campus client) → .features 1 h; 5 min from cards
Building directory ['building-directory',campus] GET spaces/directory → .data (St. Olaf only) 5 min
Map categories ['map-categories'] manifest → map/categories, validated with zod 5 min select re-validates the restored cache
Map style ['map-style','stolaf',scheme] manifest only (no fetch of the style itself) 24 h; initialData = shipped href Carleton uses https://carls-app.github.io/map-tiles/style.json
Transit modes ['transit','modes'] GET transit/modes → .data default
Bus ['transit','bus-routes'] GET transit/bus → .data default
Dictionary ['dictionary'] GET dictionary → .data 5 min
Contacts ['contacts'] GET contacts → .data 5 min
Departments ['directory','departments'] GET directory/departments → .results, sorted by name.localeCompare 24 h
Directory search ['directory',{format:'json',<field>:q}] GET https://www.stolaf.edu/directory/search?format=json&{department|firstname|lastname|major|query|title|email}=<trimmed q> 1 min direct
FAQs ['faqs'] GET faqs default normalised, falls back to local FAQs
News ['news','named',id] manifest → stolaf/mess WordPress (direct) default
A–Z ['a-z'] manifest → wp.stolaf.edu A–Z (direct) plus a-to-z/extras (failures ignored) default
Calendar ingest ['calendar','named',id] manifest → TEC (direct, paged, today through +1 month) / Presence (direct) 1 h not persisted; writes SQLite
Radio schedules ['schedule',id] GET calendar/named/{krlx,ksto}-schedule default persisted
Now playing ['radio-now-playing',station,url] KRLX metaradio (direct) refetchInterval = feed refreshMs or 60 s, while playing persist false
Jobs board ['jobs','postings'] Oracle HCM REST (direct): categories, then one requisitions call per category (limit 200, POSTING_DATES_DESC) 5 min
Job detail ['jobs','detail',id] Oracle recruitingCEJobRequisitionDetails (direct) default
Posting units ['jobs','posting-units'] manifest → student-work/units 1 h
Student Work areas / wages ['student-work-areas'], ['student-wages'] manifest → student-work/areas, student-work/wages 5 min zod-validated
Course catalog ['catalog','course-catalog'] manifest → courses/catalog.db (307 → https://stolaf.dev/course-data/catalog-recent.db), If-None-Match 1 h not persisted
Messenger ['mess',…] https://olafmessenger.com/wp-json/wp/v2/* via the manifest's mess feed (direct); news/mess/wp/v2/* if the manifest points there 5 min to 1 day see Messenger chapter
Images none (RN Image, cache: 'force-cache') GET images/{contacts,news-sources,spaces,streaming,webcams}/{name}.webp against the API root HTTP cache prefetch errors ignored
Balances ['balances',username] POST https://www.stolaf.edu/apps/olecard/checkbalance/authenticate.cfm (form username, password, credentials: include), then GET …/api.cfm default direct
Credentials ['credentials','stolaf.edu'] Keychain read networkMode 'always', gcTime 0, staleTime 0
StoPrint ['printing',…] https://papercut.stolaf.edu/rpc/api/rest/internal/… (direct); color printers via GET printing/color-printers default
Dev routes ['routes'] GET routes default
Dev API test ['api-test',name] GET {name} as text 0 / gcTime 0
Server URL ['settings','server-url'] AsyncStorage read default

Endpoints the app calls on ccc-server (St. Olaf base unless noted)

  • sources
  • orgs, orgs/uri/:uri, orgs/categories, orgs/category-styles
  • webcams
  • streams/upcoming
  • food/named/menu/{stav-hall,the-cage,the-pause,burton,ldc,sayles,weitz,schulze}
  • food/named/cafe/{stav-hall,the-cage,burton,ldc,sayles,weitz,schulze}
  • food/menu/:cafeId, food/cafe/:cafeId (dev picker only)
  • athletics/scores
  • spaces/hours (both institutions)
  • spaces/directory
  • map/geojson (both institutions), map/categories, map/style, map/style-dark
  • transit/bus, transit/modes
  • dictionary, contacts, faqs
  • directory/departments
  • a-to-z/extras
  • calendar/named/krlx-schedule, calendar/named/ksto-schedule
  • student-work/areas, student-work/wages, student-work/units
  • courses/catalog.db
  • printing/color-printers
  • images/:group/:name
  • routes (dev)
  • news/mess/wp/v2/*: only if the published manifest points the Messenger there; the bundled and published copies currently do not.

Hosts the app calls directly (bypassing ccc-server)

  • wp.stolaf.edu: news, A–Z, TEC calendar.
  • olafmessenger.com: Messenger.
  • api.presence.io: calendar.
  • fa-ewur-saasfaprod1.fa.ocs.oraclecloud.com: jobs.
  • www.stolaf.edu: /directory/search, /apps/olecard/checkbalance/*, and the KSTO player page in a web view.
  • papercut.stolaf.edu: StoPrint.
  • content.krlx.org: now playing.
  • cdn.stobcm.com, s3.voscast.com:10803: audio streams.
  • carls-app.github.io/map-tiles/style.json: Carleton basemap.
  • stolaf.dev/course-data/catalog-recent.db: reached by redirect.
  • carleton.frogpond.tech: Carleton's ccc-server.
  • Tiles, glyphs and sprites named inside the map styles.
  • Image URLs embedded in third-party payloads (WordPress media, webcam thumbnailUrl).

ccc-server: shared behaviour the app relies on

  • Mounting:
    • INSTITUTION=stolaf-college|carleton-college serves routes at the root (/v1/..., /ping, /, /_cache).
    • INSTITUTION=all serves /stolaf/... and /carleton/... plus a global /ping (ccc-server:source/ccc-server/app.ts:16-39).
    • Production hosts are single-institution, so https://stolaf.frogpond.tech/v1/<path> maps to /v1/<path> (inferred from DEFAULT_URL).
  • Middleware order: access log → koa-compress → conditionalGet → @koa/etag → cacheControl helper → body parsers → router (app.ts:46-63). The response cache is router-level middleware per institution (ccci-stolaf-college/index.ts:36-38).
  • Response cache (ccc-koa/cache.ts, ccc-server/response-cache.ts):
    • An LRU of up to 10 000 entries per institution, default max age 1 day.
    • Keyed by the full request URL, query string included (cache.ts:275).
    • Only GET/HEAD responses with status 200 and a non-empty body are stored, and only for routes that call ctx.cached(ttl).
    • Stored with each copy: body, Content-Type, Last-Modified and ETag, plus the link, x-wp-total and x-wp-totalpages headers (stored-headers.ts).
    • Concurrent misses for a key share one upstream fetch, gated by CACHE_FILL_DEDUPE_PERCENT; waiters share errors and non-200 replies too.
    • Every cached route sends Vary: Accept-Encoding.
  • Cache-Status header (RFC 9211, name ccc-server):
    • hit; ttl=<s> on a hit.
    • fwd=uri-miss; stored on a fill, with fwd-status=200 when it answered 304.
    • fwd=uri-miss; collapsed[; stored] for a waiter.
  • Cache-Control:
    • ctx.cacheControl(ms) sets public, max-age=<s>, or private, no-cache, no-store for false.
    • On a hit, max-age counts down to the copy's remaining life (cache.ts:421-430).
  • Conditional GET: every response gets an ETag from @koa/etag. A request with a matching If-None-Match or If-Modified-Since gets 304 with no body (conditional-get.ts, cache.ts:443-446). The app sends If-None-Match itself only for the course catalog. iOS's shared URLCache (4 MiB memory / 20 MiB disk, set in the AppDelegate, plugins/with-app-delegate-customizations.ts:30-34) may revalidate or serve other GETs according to Cache-Control (inferred).
  • Error shapes:
    • Koa ctx.throw/ctx.assert answer with the status and a plain-text message, for example 400 ?id is required or 404.
    • Unhandled upstream failures answer 500 Internal Server Error (Koa default, inferred).
    • The app never parses ccc-server error bodies, except that fetchSourceBody reads a WordPress code when the error body is JSON (fetch-source.ts:104-110).
  • Retired endpoints answer 200 with a one-item "notice" payload rather than a 404:
    • Titles Temporarily unavailable / No longer updated / Deprecated endpoint.
    • Link https://github.com/frog-pond/ccc-server/discussions/564 (ccc-lib/deprecated.ts, feeds/wp-json.ts:98-110, calendar/deprecated.ts:8-24).
  • Ping and diagnostics:
    • GET /ping answers pong; HEAD gives an empty 200.
    • GET / answers Hello world!.
    • GET /_cache lists cache keys with seconds left, itself cached 10 s.
    • DELETE /_cache[?key=…] evicts and answers 204 with X-Cache-Deleted (helpers.ts:80-119).
    • GET /v1/routes lists {path, displayName, params} sorted by path; matching is case-insensitive.
    • The app uses only routes, in the dev API tester. It never calls ping.
  • Upstream client:
    • ky with User-Agent ccc-server/0.2.0, 30 s per attempt and 60 s total (ccc-lib/http.ts:4,58-64).
    • The Messenger proxy times out at 7 s, so a last good copy still beats the app's 10 s timeout (v1/mess.ts:190-192).
  • Proxy trust: X-Forwarded-For is trusted with maxIpsCount: 1. A client hang-up mid-response is not reported as an error (behind-proxy.ts, client-abort.ts).
  • mDNS (dev): with ADVERTISE_MDNS=1 the server advertises _ccc-server._tcp with TXT institution=<inst> and path=/v1/, or / for all (server.ts:54-155).

ccc-server endpoint table: St. Olaf (ccc-server:source/ccci-stolaf-college/index.ts)

All routes are GET unless noted. TTL is the server response-cache max age and equals Cache-Control: max-age unless noted. "App" says whether the app calls the route.

Path (/v1/…) Query params Upstream TTL Response shape App
food/item/:itemId none BonApp nutrition for item 1 h nutrition JSON no
food/menu/:cafeId none read (the app adds ?cafeId, which only splits the cache key) BonApp cafe by id; known ids 261 stav, 262 cage, 263 kingsRoom, 35 burton, 36 ldc, 34 sayles, 458 weitz; else 400 cafeId must be one of … 1 h CafeMenuResponseType ({days, items, cor_icons, …}) dev picker
food/cafe/:cafeId same BonApp cafe info 1 h CafeInfoResponseType dev picker
food/named/menu/the-pause none stolaf.dev/AAO-React-Native/pause-menu.json 1 h {data:{foodItems,stationMenus,corIcons}} yes
food/named/{cafe,menu}/stav-hall none stolaf.cafebonappetit.com/cafe/stav-hall 1 h cafe / menu yes
food/named/{cafe,menu}/the-cage none …/the-cage 1 h yes
food/named/{cafe,menu}/kings-room none …/the-kings-room 1 h no
food/named/{cafe,menu}/the-cave none …/the-cave 1 h no
food/named/{cafe,menu}/burton none carleton.cafebonappetit.com/cafe/burton 1 h yes
food/named/{cafe,menu}/ldc none …/east-hall 1 h yes
food/named/{cafe,menu}/sayles none …/sayles-cafe 1 h yes
food/named/{cafe,menu}/weitz none …/weitz-cafe 1 h yes
food/named/{cafe,menu}/schulze none …/schulze-cafe 1 h yes
calendar/google id (required, 400 ?id is required) Google Calendar 1 min events no
calendar/ics url (required, must parse) any iCal URL 1 min events no
calendar/named/stolaf none none (retired notice event) 1 min [event] notice no (app reads TEC directly)
calendar/named/oleville none none (retired) 1 day notice no
calendar/named/northfield none northfieldmn.gov iCal 1 min events no
calendar/named/krlx-schedule none Google Calendar krlxradio88.1@gmail.com 1 min events (frogpond.events+json) yes
calendar/named/ksto-schedule none stolaf.dev/AAO-React-Native/ksto-schedule.json expanded to a weekly schedule 1 min events yes
a-to-z none none (deprecated notice link group) 1 day [{title,data:[{label,url}]}] no
a-to-z/extras none GH Pages a-to-z.json 1 day JSON passthrough yes
sources none GH Pages sources.json 1 day JRD yes
dictionary none GH Pages dictionary.json 1 day {data:[word]} yes
directory/departments none www.stolaf.edu/directory/departments?format=json 1 day {results:[{name,…}]} yes
directory/majors none www.stolaf.edu/directory/majors?format=json 1 day JSON no
contacts none GH Pages contact-info.json 1 day {data:[contact]} yes
tools/help none GH Pages help.json 1 day JSON no
faqs none GH Pages faqs.json 1 day {faqs?, text?} yes
webcams none GH Pages webcams.json 1 day {data:[webcam]} yes
images/:group/:name none allowed (any query → 404) stolaf.dev/AAO-React-Native/img/<group>/<name>. Group must be one of contacts, news-sources, spaces, streaming, webcams; name matches ^[a-z0-9]+(-[a-z0-9]+)*\.webp$, else 404. Upstream 404 → 404 with no Cache-Control; other failure → 502 1 day image/webp bytes yes
jobs none none (deprecated notice job) 1 day [job] notice no
map none stolaf.dev/campus-map-data/map.json 1 h JSON no
map/geojson none …/campus-map-data/map.geojson 1 h FeatureCollection yes
map/categories none GH Pages map-categories.json 1 day {data:{stolaf:{groups,icons},carleton:{…}}} yes
map/style, map/style-dark none …/campus-map-data/style.json, style-dark.json 1 h MapLibre style yes (by URL in MapLibre)
orgs none Presence api.presence.io/stolaf/v1, without Demo orgs 36 h [org] yes
orgs/categories none Presence categories 36 h [{category, uris…}] yes
orgs/category-styles none GH Pages org-categories.json 1 day {data:[…]} yes
orgs/uri/:uri none; uri must match ^[a-z0-9]+(-[a-z0-9]+)*$, else 404 before the cache Presence org page 36 h org detail, or 404 No student org has the uri … yes
student-work/units none Oracle HCM (server-side) plus GH Pages areas 1 h {postingId: unit | "other" | null} yes
student-work/areas none GH Pages student-work-areas.json 1 day {data:[area]} yes
student-work/wages none GH Pages student-wages.json 1 day {data:…} yes
courses/catalog.db none 307 redirect to https://stolaf.dev/course-data/catalog-recent.db, Cache-Control: max-age=3600, not response-cached n/a redirect yes
news/rss url required any RSS 1 h [FeedItem] no
news/wpjson url required any WP JSON 1 h [FeedItem] no
news/named/stolaf none none (notice) 1 h [FeedItem] notice no
news/named/oleville, politicole, ksto none none (Deprecated endpoint) 1 h [FeedItem] no
news/named/mess none olafmessenger.com posts, per_page 10 1 h [FeedItem] no
news/named/krlx none content.krlx.org/feed/ 1 h [FeedItem] no
news/mess/wp/v2/:resource[/:id] allow-listed per resource (per_page 1–100, page, _embed, _fields, categories, include ≤100 ids, staff_name, staff_year, hide_empty, slug); unknown or duplicate → 400, unknown resource → 404 olafmessenger.com WP REST, 7 s timeout. On outage (timeout, 5xx, 403/408/429, non-JSON) it serves the last good copy for up to 7 days with TTL 1 min; no copy → 502 posts list 5 min, post 1 h, categories, media, staff, pages 1 day WordPress JSON with x-wp-total, x-wp-totalpages and RFC 8288 Link paging only if the manifest points here
spaces/hours none GH Pages building-hours.json 1 h {data:[building]} yes
spaces/directory none GH Pages building-directory.json 1 h {data:[directory]} yes
transit/bus none GH Pages bus-times.json 1 h {data:[line]} yes
transit/modes none GH Pages transportation.json 1 h {data:[mode]} yes
streams/upcoming dateFrom, dateTo (ISO dates, optional), sort (ascending/descending, default ascending) www.stolaf.edu/multimedia/api/collection class=current; starttime converted from America/Chicago to ISO 1 h [stream] yes
streams/archived same; default the last 2 months same, class=archived 1 h [stream] no
streams/search query (required), sort, class (archived/upcoming/all), category, count, offset, dateFrom, dateTo same 1 h streams with Link paging no
printing/color-printers none GH Pages color-printers.json 1 day JSON yes
reports/stav none stolaf.dev/stav-mealtimes/two-weeks.json 1 h JSON no
util/html-to-md POST JSON {text} (≤100 kb; non-JSON → 415; missing → 400); GET kept none uncached Markdown text no
athletics/scores none athletics.stolaf.edu/services/scores_chris.aspx?format=json (team Oles) 5 min; 1 min while games need frequent refresh [Score] with status.indicator yes
routes none none uncached [{path,displayName,params}] dev

"GH Pages" means https://stolaf.dev/AAO-React-Native/<file> (v1/gh-pages.ts).

ccc-server endpoint table: Carleton (ccc-server:source/ccci-carleton-college/index.ts)

"Carls Pages" means https://carls-app.github.io/carls/<file> (v1/gh-pages.ts). The app calls only spaces/hours and map/geojson here.

Path (/v1/…) Upstream TTL / notes App
food/* (same 25 routes as St. Olaf) BonApp, same tables 1 h no
calendar/google, calendar/ics as St. Olaf 1 min no
calendar/named/carleton carleton.edu calendar feed 1 min no
calendar/named/the-cave none (retired notice) 1 day no
calendar/named/stolaf none (retired notice) 1 day no
calendar/named/northfield, krlx-schedule, ksto-schedule as St. Olaf 1 min no
calendar/named/upcoming-convos, convos/upcoming carleton.edu convocations feed 1 min no
calendar/named/sumo-schedule carleton.edu SUMO feed 1 min no
convos/upcoming/:id, convos/archived podbean feed / convocations page 6 h no
dictionary Carls Pages dictionary-carls.json not response-cached (no ctx.cached); max-age=3600 no
faqs Carls Pages faqs.json not response-cached; max-age=3600 no
webcams Carls Pages webcams.json not response-cached; max-age=3600 no
contacts, tools/help Carls Pages 1 h no
images/:group/:name AAO GitHub Pages (shared) 1 day no
jobs carleton.edu student-employment WP, without Archived 1 day no
map, map/geojson carls-app.github.io/map-data/map.json, map.geojson 1 h geojson: yes
orgs none (unavailable notice) 6 h no
news/rss, news/wpjson any URL (url required) 1 h no
news/named/nnb retired 6 h no
news/named/carleton-now carleton.edu news WP 1 h no
news/named/carletonian thecarletonian.com feed 1 h no
news/named/krlx content.krlx.org feed 1 h no
news/named/covid deprecated 1 h no
spaces/hours Carls Pages building-hours.json 1 h yes
transit/bus, transit/modes Carls Pages 1 h no
util/html-to-md (POST/GET), athletics/scores (athletics.carleton.edu), routes as St. Olaf as St. Olaf no

Persisted state

Inventory: AsyncStorage (one store per app; the iOS library is "SharedStorage" under Application Support/)

Key Owner Shape / default Version / migration Affects
REACT_QUERY_OFFLINE_CACHE (library default, inferred) query cache {buster, timestamp, clientState:{queries,mutations}} buster = app version + build; maxAge 24 h (inferred) every non-calendar query's saved data
persist:root redux-persist (source/redux/store.ts:22-28) {settings, buildings, courses, _persist:{version,rehydrated}} version 3; migrations 2 and 3 see redux below
aao::settings:server-address source/lib/storage.ts:54-62 JSON string; '' means default none API root
aao::settings:open-links-in-app storage.ts:45-52 JSON boolean, default true none Open Links In (In App vs Safari)
telemetry-consent expo-sqlite kv-store, not AsyncStorage (features/telemetry/store.ts:12-18,55-60) {state:{enabled:true, deviceId:null}, version:1} v1 Sentry enablement and user id
radio-preferences radio store {showOnHome:true, logoIndexes:{}, viewedStationId:'ksto'} v1 Home radio bar, logos, last station
mess-preferences Messenger {lastSign:null, openedStories:[], stainKind:'coffee', photoTone:'auto', keepPhotoStoriesDark:true} v2; v1 (sign only) keeps a known zodiac sign Messenger
developer-preferences developer {floatingButtonEnabled:false} v1 DebugSwift floating button
athletics-filter-preferences athletics {selectedSports:[]} (availableSports not saved) v1 sports filter
student-work-seen-postings Student Work {seenIds:null}; an empty list is never saved v1 "new" dots
student-work-area-layout Student Work {layout:'grid'} v1 grid/list
student-orgs-category-layout Student Orgs {layout:'list'} v1 grid/list
quick-actions quick actions {quickActions: DEFAULT_QUICK_ACTIONS} v1 Home Screen quick actions
news-filter-preferences news {selectedCategories:{}} v2; v1 is discarded per-source category
map-recent-places map {recent:{stolaf:[],carleton:[]}} v1 recents
faq-banner-preferences FAQs {dismissed:{[faqId]:{version,dismissedAt}}} v1 banner dismissal
calendar-filter-preferences calendar {filter:null, mode:'day'} v3; v2 selectedCategory becomes {axis:'category', value} calendar filter and mode

Zustand persist writes {state, version} JSON under the bare name. Stores with hydrated (radio, layouts) set it once a read has finished, even when the read failed (radio/store.ts:157-159, layout-store.ts:45).

The prefixed helper module builds keys as `${prefix}:${key}` with prefix 'aao:', which gives a double colon (modules/storage/index.ts:16, source/lib/storage.ts:14).

storage.ts also defines helpers that nothing calls; they are dead code:

  • homescreen:view-order
  • homescreen:disabled-views
  • settings:ackd
  • buildings:favorited
  • sis:course-data:*
  • courses:recent-searches
  • courses:recent-filters

Redux slices (persist:root, autoMergeLevel1, so each slice is swapped in whole)

  • settings (parts/settings.ts:27-33):
    • unofficialityAcknowledged (false)
    • devModeOverride (false)
    • enabledCalendarSources: ['stolaf','presence'], or ['uitest'] under UI test
    • directoryResultsView: 'tiles'
    • Selectors fall back to the defaults when a field is missing (:74-78).
  • buildings: favorites: [{campus,name}], kept sorted by name.localeCompare on toggle (parts/buildings.ts:34-47).
  • courses:
    • recentFilters: up to 3, newest first, skipping an empty description or a duplicate description.
    • recentSearches: up to 3, de-duplicated case-insensitively (parts/courses.ts:44-52).
  • Migrations (migrations.ts):
    • 2: adds presence to enabledCalendarSources. A missing list becomes the full default list, and an explicit empty list becomes ['presence'].
    • 3: converts bare-string favourites to {campus:'stolaf', name}.
  • Sentry: the Redux enhancer sends no state and only the action type (sentry-enhancer.ts).

SQLite and files

  • aao.db (expo-sqlite, Documents/SQLite; source/database/client.ts:101).
    • Tables: meta(key PK, value), event, occurrence, event_tag, plus indexes (calendar/schema.ts:20-55).
    • On open the app runs pragma foreign_keys = on.
    • Rebuild rule: meta.fingerprint = "<SCHEMA_VERSION=2>|<device IANA zone>". A mismatch drops visible_event, event_tag, occurrence, event and meta, then recreates them (schema.ts:5,22-67). The check runs once per process.
    • A database that will not open is deleted and recreated (client.ts:54-63).
    • Calendar rows older than 30 days are pruned on write (calendar/write.ts:16,37-50).
  • Caches/course-catalog.db: attached as schema catalog. It holds aao_download(etag) and the FTS5 index course_fts (courses/catalog-file.ts:10, index-build.ts:118). It is downloaded to course-catalog.next.db, checked and indexed there, then moved into place. A file that cannot be opened or searched is deleted.
  • Caches files: images shared through the share sheet are downloaded into Caches (components/lib/share-image.ts:13).
  • Documents (test or dev only): fixture-recording.jsonl (Messenger fixture recording), chaos-tape-<n>.jsonl, chaos-findings.jsonl.
  • URLCache.shared: 4 MiB in memory, 20 MiB on disk. It holds HTTP responses, including force-cache images.

Keychain, UserDefaults, system

  • Keychain: internet credentials for server stolaf.edu (SIS/OleCard username and password; login.ts:15,37-51).
  • UserDefaults: DebugSwiftEnabled (modules/debug-tools/ios/DebugToolsModule.swift:22).
  • System: the alternate app icon (react-native-change-icon) and the iOS Home Screen quick actions (synced from the quick-actions store).

What resets clear

Mechanism Query cache AsyncStorage (redux, zustand, aao::) aao.db and catalog telemetry kv-store Keychain App icon UserDefaults
Developer Reset Everything → refreshApp (lib/refresh.ts) yes yes (AsyncStorage.clear()) yes no yes (stolaf.edu) yes, reset to Default no
UI-test reset channel (lib/uitest-reset.ts) yes yes yes no no no yes (native)
--reset-state launch argument (AppDelegate) yes (file removed) yes (Application Support/ and RCTAsyncLocalStorage_V1) yes (Documents/SQLite) yes (same SQLite folder, inferred) no no yes

clearStoredData (lib/clear-stored-data.ts:11-32) runs in this order:

  1. Pause redux-persist, then flush it.
  2. queryClient.clear().
  3. persister.removeClient().
  4. AsyncStorage.clear().
  5. dropDatabase(): close the database, delete aao.db (a "not found" error is not reported), and delete the catalog file. It never throws.

After clearing, refreshApp always restarts the JS app via Restart(), even if the Keychain or icon step fails.

Behaviors and edge cases

  • Launch order (app/_layout.tsx:1-17): chaos init (when enabled), constants, logbox, moment, sentry, API roots, query client. Redux rehydration gates the UI behind Loading App.... The query cache restore then runs inside the provider.
  • Server URL validity:
    • An http:/https: URL is accepted. Any other text disables Save and shows Invalid URL!.
    • A missing trailing slash is added on save, because without it ky would replace the last path segment (constants.ts:4-5).
    • The new address applies after the automatic restart.
    • The Carleton client is unaffected.
    • The dev address also applies to apiUrl() (map style, course catalog, radio relative hrefs) and to images.
  • Relative vs absolute href: absolute means it matches ^[a-z][a-z\d+.-]*: (case-insensitive; fetch-source.ts:11-15). Relative hrefs go through client, so they honour the override. Absolute hrefs bypass it.
  • Abort: unmounting a screen aborts its in-flight fetches through React Query's signal. The 10 s timer is cleared (fetch-source.test.ts "unmounting…").
  • Manifest offline: fetchManifest never waits for the network. Offline it resolves immediately to the bundled copy (resolve.ts:20-28; test fetchManifest resolves to the bundled document rather than hanging while offline).
  • Stale-while-error:
    • After a failed refetch, a screen keeps showing the data it had (listState, menuView).
    • The cached data is not overwritten on disk: a failed query is not dehydrated, so the previously saved successful copy remains until the next successful write (inferred from the throttled whole-cache writes). The exception is persistAfterFailure, which is written explicitly as success.
  • Restored data is not re-validated by fetch code. This is why the buster drops caches from other builds and why map categories re-validate in select (map/category-groups-query.ts:83-90,98).
  • Calendar data is never persisted in the query cache. A cold launch always refetches calendar sources (stale 1 h within a session), and reads come from SQLite. A time-zone change is noticed only at the next cold start, when the database is wiped.
  • Course catalog conditional download (courses/refresh.ts:72-145):
    • Concurrent callers share one refresh.
    • If-None-Match lists the stored ETag and the last rejected ETag.
    • 304 with a stored copy means unchanged.
    • A rejected ETag throws CatalogRejectedError and is not retried.
    • A non-OK status throws The course catalog could not be downloaded (HTTP <status>).
    • Index batches yield to the UI between batches.
    • The file swap is atomic with respect to reads.
  • Athletics polling: every 30 s while any game is in play, every 5 min otherwise or with no data (athletics/query.ts:12-32). This follows the server's own TTL switch from 5 min to 1 min.
  • Now playing: polled only while that station is loaded and playing or starting. The interval is the feed's refreshMs, else 60 s (use-now-playing.ts:37-44).
  • Images: requested with cache: 'force-cache', so once cached they show with no network and are never revalidated. A changed picture must be published under a new name (remote-images.ts:35-44). Prefetch failures are swallowed.
  • Directory search query is trimmed. username maps to the email parameter (directory/query.ts:15-38).
  • BonApp menu keys include the campus day, so a new day fetches fresh even while the previous day's data is fresh (menus/query.ts:23-36).
  • Query failure telemetry (telemetry/query-failures.ts):
    • Fires on every query cache updated event whose action is error, so after React Query's retries.
    • Sends the Sentry log api.failure with {source: first key element if it is a string, else 'unknown'; kind; status}.
    • Kind and status:
      • SourceFetchError → http with its status.
      • ky HTTPError → http with the response status.
      • ky TimeoutError → timeout, 0.
      • ky NetworkError → network, 0.
      • anything else → other, 0.
    • Nothing is sent when the person has opted out, because Sentry is closed.
  • Sentry request data:
    • Native network breadcrumbs are off (enableNetworkBreadcrumbs: false).
    • JS breadcrumbs, spans and events have query strings and fragments stripped from URLs (scrub.ts:19-24).
    • enableCaptureFailedRequests: true (sentry.ts:39).
  • Course catalog errors are additionally sent to Sentry with captureException (courses/refresh.ts:132, client.ts:91,135,145).
  • The UI test fixtures replace network data for these features:
    • orgs
    • org categories
    • menus (Stav and The Pause)
    • athletics
    • St. Olaf hours
    • bus
    • transit modes
    • dictionary
    • contacts
    • directory search
    • map
    • map categories
    • building directory
    • jobs
    • Student Work areas and wages
    • calendar (uitest source)
    • balances
    • course catalog (in-memory)
  • Known discrepancy: the app comment says ccc-server reads cafeId only from the query string. The server reads :cafeId from the path (ccc-server:…/v1/menu.ts:52; test /food/menu/:cafeId reads the cafe from the path). The app sends both, so this is harmless; the extra ?cafeId just splits the server cache key.

Accessibility

  • Shared notices use SwiftUI ContentUnavailableView, so the title, description and action are native accessibility elements. The action is a bordered, large button with the given label (Try Again).
  • The Developer screen host carries identifier screen-developer (app/developer/index.tsx:21).
  • No other accessibility-specific code lives in this area. Dynamic Type follows the native controls.

Platform and environment differences

  • ATS (app.config.ts:227-236):
    • Arbitrary loads are allowed in web content.
    • localhost may use insecure HTTP.
    • s3.voscast.com does not require forward secrecy.
    • The ATS exceptions name only localhost, so plain-HTTP requests to a LAN server found over mDNS (http://<ip>:port) may be blocked by ATS (inferred).
  • Bonjour: NSBonjourServices: ['_ccc-server._tcp.'], with local-network usage string Used in development mode to discover a local ccc-server instance on the same network.
  • Dev mode (useIsDevMode) is a debug build OR settings.devModeOverride. It gates the Developer screen and mDNS discovery.
  • The dev "force bundled data" switch (in memory, not persisted) makes St. Olaf hours and the building directory read the bundled JSON (building-hours/dev/data-source-store.ts).
  • Launch arguments:
    • --uitesting: fixtures, and ['uitest'] calendar sources.
    • --reset-state: native wipe at launch.
    • --record-fixtures
    • --uitest-reset-channel <dir>: in-place reset between tests. The JS side renders nothing, clears storage, the native side clears UserDefaults and answers, and the JS restarts.
    • --chaos, --chaos-seed, --chaos-launch, --chaos-replay, --chaos-fault-rate, --chaos-profile: wrap fetch with injected faults: latency 0.5–8 s, status 404/500, network failure, empty, malformed or truncated body, mutated JSON, and timed offline windows (source/chaos/faults.ts, network.ts).
  • probe() writes [probe] <ms> <message> to the native log hook in CI, around the UI-test reset (lib/probe.ts, uitest-reset.ts:31-35).
  • focusManager ignores AppState on web (tanstack-query.ts:163); not relevant on iOS.
  • The AppDelegate sets the audio session category to playback (outside this area).

Requirements

Base URL and client

  • DATA-001 [MUST] With no saved server address, every relative request goes to https://stolaf.frogpond.tech/v1/<path>. Verify: manual, network inspector; source/lib/__tests__/remote-images.test.ts (default root). Source: source/lib/constants.ts:3, source/init/api.ts:11-19
  • DATA-002 [MUST] Carleton building hours and map geojson are fetched from https://carleton.frogpond.tech/v1/ regardless of the server override. Verify: manual, with the override set, open Hours › Carleton. Source: source/init/api.ts:9, source/features/building-hours/query.ts:31-33, source/features/map/query.ts:22-24
  • DATA-003 [DEV] Saving a server address that does not end in / stores it with a trailing /. Verify: manual. Source: source/lib/storage.ts:56-59
  • DATA-004 [DEV] Saving the server address restarts the app, and afterwards all relative requests, image URLs and apiUrl() hrefs use the new root. Verify: manual. Source: source/features/developer/server-url.tsx:40-46, source/lib/api-url.ts:13-15
  • DATA-005 [DEV] An empty server address means the default URL. Verify: manual. Source: source/init/api.ts:14-16, server-url.tsx:53
  • DATA-006 [DEV] The Save row reads Invalid URL! and is disabled for non-empty text that is not an http(s) URL. Verify: manual. Source: server-url.tsx:12-19,48-49,72-76
  • DATA-007 [DEV] While the stored address loads, the field is disabled with placeholder Loading…. Afterwards the placeholder is https://stolaf.frogpond.tech/v1/. Verify: manual. Source: server-url.tsx:54-70
  • DATA-008 [DEV] In dev mode, ccc-server instances advertised as _ccc-server._tcp are listed under Local Servers as http://<addr>:<port><txt.path or /v1/>. An IPv6 address is bracketed. Tapping one fills the field. Verify: source/features/developer/__tests__/use-server-discovery.test.ts. Source: use-server-discovery.ts:19-67
  • DATA-009 [DEV] A discovered server that goes away disappears from the list, and discovery stops when the screen unmounts. Verify: use-server-discovery test "removes only the matching discovered server", "cleans up listeners on unmount". Source: use-server-discovery.ts:51-64
  • DATA-010 [MUST] Image URLs are <root>images/<group>/<name>.webp, where group is one of contacts, news-sources, spaces, streaming or webcams. Images are requested with force-cache. Verify: remote-images.test.ts, published-images.test.ts. Source: source/lib/remote-images.ts:16-44
  • DATA-011 [SHOULD] Image prefetch fetches each distinct URL once and ignores failures. Verify: remote-images test "fetches each image once…". Source: remote-images.ts:52-54

Fetching sources

  • DATA-012 [MUST] A source href with a URI scheme is fetched directly. One without a scheme is resolved against the configured API root. Verify: modules/data-sources/__tests__/fetch-source.test.ts. Source: modules/data-sources/fetch-source.ts:11-15,97-113
  • DATA-013 [MUST] A direct (absolute) source fetch is aborted after 10 seconds. Verify: fetch-source test "an absolute fetch that never resolves is aborted after ten seconds". Source: fetch-source.ts:3,26-41
  • DATA-014 [MUST] Leaving a screen aborts its in-flight source fetch without waiting for the timeout. Verify: fetch-source test "unmounting… aborts". Source: fetch-source.ts:29-31
  • DATA-015 [MUST] An error status from any source produces an error whose message is <label> fetch failed: <status> and which carries the status, plus the WordPress code when the body is JSON with one. Verify: fetch-source tests "carries the code WordPress named" and "carries no code…". Source: fetch-source.ts:47-78,104-121
  • DATA-016 [MUST] Text-format sources (RSS, Atom, iCal) are read as text, whether relative or absolute. Verify: fetch-source "text format" tests. Source: fetch-source.ts:95-123
  • DATA-017 [MUST] A list response drops individual malformed items. If every item of a non-empty list is malformed, the load fails with every <what> was malformed. An empty list is an empty state. Verify: parse-each.test.ts. Source: modules/data-sources/parse-each.ts:14-33

Manifest

  • DATA-018 [MUST] The app fetches the sources manifest from GET sources at most once a day per session cache (staleTime 24 h). It does not retry, and it runs even when the device reports offline. Verify: code review; resolve.test.ts. Source: modules/data-sources/resolve.ts:16-37
  • DATA-019 [MUST] When the manifest cannot be fetched or parsed, features resolve their sources from the bundled manifest immediately, including while offline. Verify: resolve test "fetchManifest resolves to the bundled document rather than hanging while offline". Source: resolve.ts:41-47
  • DATA-020 [MUST] A source missing from the fetched manifest, or of an unsupported type, falls back to its bundled entry. An id with no usable entry anywhere throws, or is dropped from lists. Verify: resolve tests "rule 2", "rule 3", "resolveSources…". Source: resolve.ts:79-116
  • DATA-021 [MUST] A previously fetched manifest stays saved on disk even when a later refresh fails. Verify: tanstack-query.test.ts "the sources manifest stays in storage after a refresh fails". Source: resolve.ts:32, tanstack-query.ts:82-84,144
  • DATA-022 [MUST] The bundled manifest carries the entries in the Data table (news stolaf/mess/oleville, A–Z stolaf/extras, calendar stolaf/presence/krlx/ksto, jobs, org categories, map categories, student work ×3, map styles, course catalog, radio). Verify: resolve tests "the bundled manifest is a valid JRD document", "…carries the St. Olaf jobs site", "…offers Presence as a calendar". Source: modules/data-sources/bundled.json
  • DATA-023 [MUST] Manifest links are validated: rel is a URL, href is a non-empty URI reference without whitespace, and the id property is required. Verify: types.test.ts. Source: modules/data-sources/types.ts:30-45
  • DATA-024 [MUST] Radio sources use the cached manifest however old it is, and wait (render nothing) only while the saved cache is being restored. Verify: code review. Source: modules/data-sources/use-manifest.ts:18-25
  • DATA-025 [MUST] A radio player page is loaded only from https://www.stolaf.edu. Any other published host falls back to the shipped page. Verify: code review. Source: source/features/streaming/radio/sources.ts:25,96-116

Query cache and persistence

  • DATA-026 [MUST] Unused query data is kept in memory for 24 hours. Verify: code review. Source: source/init/tanstack-query.ts:20-28
  • DATA-027 [MUST] Successful query data is saved to device storage and shown on the next launch before any refetch completes. Verify: manual, airplane mode relaunch shows cached Menus. Source: tanstack-query.ts:90-148, app/_layout.tsx:112
  • DATA-028 [MUST] Failed and pending queries are not saved, except those that opt in. Verify: tanstack-query test "still refuses a query that has not succeeded". Source: tanstack-query.ts:145
  • DATA-029 [MUST] Calendar-ingest and calendar-read results (keys starting calendar) are never saved to the query cache on disk. Radio broadcast schedules (schedule) are. Verify: tanstack-query tests "shouldDehydrateQuery…", "still persists a broadcast schedule". Source: tanstack-query.ts:110-113,140
  • DATA-030 [MUST] Queries marked persist: false (course reads, now playing, Messenger lead text, non-front Messenger issues) are never saved. Verify: tanstack-query test "stays out of storage with…". Source: tanstack-query.ts:142
  • DATA-031 [MUST] Paged lists that opt in save only their first page, as loaded, even after a later page failed. Verify: tanstack-query tests "is written with only its first pages…", "persists its pages after a further page fails". Source: tanstack-query.ts:75-88,143
  • DATA-032 [MUST] A saved query cache written by a different app version or build, or with no version stamp, is discarded at launch. Verify: tanstack-query tests "a cache saved by another build…". Source: tanstack-query.ts:117-121
  • DATA-033 [SHOULD] A saved query cache older than 24 hours is discarded at launch (library default maxAge). Verify: manual / library test. Source: tanstack-query.ts:115-148 (no maxAge override)
  • DATA-034 [MUST] Pull-to-refresh on a paged list reloads from page 1 and drops later pages, which reload as the list scrolls. Verify: code review. Source: source/lib/infinite-data.ts:28-34
  • DATA-035 [MUST] When the app returns to the foreground, stale queries on visible screens refetch. Verify: manual. Source: tanstack-query.ts:162-166
  • DATA-036 [MUST] When NetInfo reports a connection again, paused fetches resume and stale queries refetch. While NetInfo reports no connection, fetches are paused rather than failed. Verify: manual, toggle airplane mode. Source: tanstack-query.ts:155-159

Offline and error UI

  • DATA-037 [MUST] A screen with saved data keeps showing it when a refetch fails. Verify: modules/notice/__tests__/list-state.test.ts "shows the list while a refetch fails…". Source: modules/notice/list-state.ts:19
  • DATA-038 [MUST] A first load that fails with nothing saved shows Couldn’t Load, the error's message (or Something went wrong.) and a Try Again button that retries. Verify: NoticeView.test.tsx. Source: modules/notice/notice.tsx:70-99
  • DATA-039 [MUST] A first load waiting for a connection, with the device offline, shows the Offline notice (wifi.slash) rather than a spinner. A pause while online keeps the spinner. Verify: list-state tests "says it is offline…", "keeps loading, not offline…". Source: list-state.ts:21-24
  • DATA-040 [MUST] An offline menu with nothing saved shows Offline / You're offline, and this menu hasn't been saved on this phone yet. It will load once you're back online. Verify: ModuleMenusTests (manual offline). Source: source/features/menus/lib/menu-view.ts:35-49
  • DATA-041 [MUST] Student Work offline with no saved board shows Offline / Student Work needs a connection to load the job board the first time. with Try Again. Verify: manual. Source: app/student-work/index.tsx:23,84-93
  • DATA-042 [MUST] Course search offline with no downloaded catalog shows Offline / Course search needs a connection to download the course catalog the first time. Verify: manual. Source: source/features/sis/course-search/lib/list-state.ts:5-6,45
  • DATA-043 [SHOULD] The app shows no global offline banner. Offline is surfaced per screen only. Verify: manual. Source: source/init/tanstack-query.ts:155-159 (only NetInfo consumer)

Per-feature request contract

  • DATA-044 [MUST] The Student Orgs list requests GET orgs, considered fresh for 5 minutes. Verify: ModuleStudentOrgsTests (fixture). Source: source/features/student-orgs/query.ts:16-32
  • DATA-045 [MUST] Org detail requests GET orgs/uri/<percent-encoded uri> once, with no retry. A 404 shows the list's data alone, without an error. Verify: code review. Source: student-orgs/query.ts:45-75
  • DATA-046 [MUST] BonApp menus and cafe info are requested from food/named/{menu,cafe}/<slug> for stav-hall, the-cage, burton, ldc, sayles, weitz and schulze. They are cached per campus day and fresh for 1 hour. Verify: ModuleMenusTests; menu-bonapp.test.tsx. Source: source/features/menus/query.ts:28-36,46-64,119-142, app/menus/*.tsx, app/carleton-*-menu.tsx
  • DATA-047 [MUST] The Pause menu is requested from food/named/menu/the-pause and read from .data. Verify: ModuleMenusTests (bundled under UI test). Source: menus/query.ts:164-174
  • DATA-048 [MUST] Athletics scores refetch every 30 seconds while any game is in play, and every 5 minutes otherwise. Verify: code review. Source: source/features/athletics/query.ts:12-32
  • DATA-049 [MUST] Building hours come from spaces/hours on each campus's own server, cached separately per campus. Verify: ModuleHoursTests. Source: building-hours/query.ts:21-61
  • DATA-050 [MUST] Map buildings come from map/geojson on each campus's own server, fresh for 1 hour (5 minutes when read by a building card). Verify: ModuleMapTests. Source: map/query.ts:12-40, map/card-queries.ts:9-25
  • DATA-051 [MUST] St. Olaf building directories come from spaces/directory. They are never requested for Carleton. Verify: code review. Source: map/directory/fetch.ts:16-22, card-queries.ts:35-40
  • DATA-052 [MUST] The St. Olaf basemap style URL comes from the manifest (map/style or map/style-dark by system appearance), resolved against the API root. Carleton always uses https://carls-app.github.io/map-tiles/style.json in light mode. Verify: code review. Source: map/style-query.ts:27-45, map/urls.ts:12,19-24
  • DATA-053 [MUST] A map-categories file of an unreadable shape fails without retry and leaves the previous table showing. A restored table of an unreadable shape is ignored. Verify: code review. Source: map/category-groups-query.ts:67-104
  • DATA-054 [MUST] Transit uses transit/bus and transit/modes. Dictionary uses dictionary (5 min). Contacts use contacts (5 min). FAQs use faqs. Webcams use webcams. All are read from .data where shown. Verify: ModuleTransitTests, ModuleCampusDictionaryTests, ModuleDirectoryTests. Source: respective query.ts files
  • DATA-055 [MUST] Departments come from directory/departments (.results), sorted alphabetically by name and fresh for 1 day. Verify: ModuleDirectoryTests. Source: directory/departments-query.ts:12-31
  • DATA-056 [MUST] Directory search calls https://www.stolaf.edu/directory/search?format=json&<field>=<trimmed query> directly, with field firstname, lastname, department, major, query, title or email (for username). Results are fresh for 1 minute. Verify: ModuleDirectoryTests (fixture). Source: directory/query.ts:8-68
  • DATA-057 [MUST] Upcoming streams request streams/upcoming?sort=ascending&dateFrom=<today campus>&dateTo=<+2 months>. Verify: ModuleStreamingMediaTests. Source: streaming/streams/query.ts:13-37
  • DATA-058 [MUST] KRLX and KSTO schedules come from calendar/named/krlx-schedule and calendar/named/ksto-schedule, hide events that have already ended, and are saved for offline. Verify: ModuleStreamingMediaTests. Source: modules/ccc-calendar/query.ts:161-181
  • DATA-059 [MUST] The St. Olaf campus calendar is fetched directly from TEC for today through the same day next month. Presence is fetched directly from api.presence.io. Both are written to SQLite and are fresh for 1 hour. Verify: ModuleCalendarTests (fixture). Source: modules/ccc-calendar/query.ts:60-146
  • DATA-060 [MUST] News (St. Olaf, Messenger), the A–Z index and the job board are fetched directly from their hosts as named by the manifest. Verify: ModuleNewsTests, ModuleStudentWorkTests. Source: news/query.ts:42-54, more/query.ts:33-57, modules/ccc-jobs/query.ts:38-90
  • DATA-061 [MUST] A failure loading A–Z extras does not blank the A–Z index. A missing upstream A–Z source fails with no A–Z index source. Verify: code review. Source: more/query.ts:39-55
  • DATA-062 [MUST] Now Playing is requested only while its station is loaded and playing or starting. It is re-polled at the feed's refreshMs (default 60 s) and never saved. Verify: code review. Source: radio/use-now-playing.ts:31-44
  • DATA-063 [MUST] Posting units come from student-work/units (1 h). Areas and wages come from the manifest's student-work/* sources (5 min) and are schema-validated. Verify: ModuleStudentWorkTests (fixtures). Source: modules/ccc-jobs/query.ts:102-117, student-work/areas-query.ts, wages-query.ts
  • DATA-064 [MUST] OleCard balances log in by POSTing username and password to https://www.stolaf.edu/apps/olecard/checkbalance/authenticate.cfm, then GET …/api.cfm. A redirect URL containing error= means login failed. Verify: manual. Source: source/lib/login.ts:53-80, lib/financials/balances.ts:23-36
  • DATA-065 [MUST] StoPrint talks directly to https://papercut.stolaf.edu/rpc/api/rest/internal/ with form-encoded bodies and Origin: https://papercut.stolaf.edu. Color printers come from ccc-server printing/color-printers. Verify: ModuleStoPrintTests. Source: source/lib/stoprint/urls.ts, api.ts:103

Course catalog

  • DATA-066 [MUST] The course catalog is requested from the manifest's courses/catalog.db, resolved against the API root, with If-None-Match listing the stored and last-rejected ETags. A 304 keeps the stored file. Verify: source/database/courses/__tests__/refresh.test.ts. Source: source/database/courses/refresh.ts:17-27,72-98
  • DATA-067 [MUST] A downloaded catalog that fails its check is rejected by ETag and not downloaded or retried again in this session. Other failures retry up to 3 times. Verify: refresh.test. Source: refresh.ts:43-45,52-53,90-92,110-115
  • DATA-068 [MUST] A new catalog replaces the old one only after it is fully checked and indexed. Searches never see a missing catalog during the swap. Verify: refresh.test. Source: refresh.ts:101-130
  • DATA-069 [MUST] The catalog is checked for updates at most hourly and is not saved in the query cache. Verify: code review. Source: source/database/courses/read.ts:42-49

SQLite

  • DATA-070 [MUST] The app database aao.db is rebuilt empty whenever the schema version or the device time zone differs from what it was built with. The check happens at the first use after a cold start. Verify: source/database/schema.test.ts. Source: source/database/schema.ts:50-67, client.ts:33-35,67
  • DATA-071 [MUST] A database that cannot be opened is deleted and recreated, with no error shown. Verify: code review. Source: client.ts:57-63
  • DATA-072 [MUST] A course catalog file that cannot be opened or searched is deleted and downloaded again on the next refresh. Verify: client.test "discards a catalog file it cannot search", "still opens when an unsearchable catalog cannot be deleted". Source: client.ts:80-99
  • DATA-073 [MUST] The catalog file lives in Caches (not backed up, purgeable). Verify: code review. Source: courses/catalog-file.ts:4-11
  • DATA-074 [MUST] Calendar rows older than 30 days are removed on each write. Verify: calendar/write.test.ts. Source: calendar/write.ts:16,37-50

Persisted settings

  • DATA-075 [MUST] The Open Links In choice is stored under aao::settings:open-links-in-app (default In App). Verify: customize/__tests__/open-links-in.test.ts. Source: source/lib/storage.ts:45-52
  • DATA-076 [MUST] Telemetry consent and device id are stored synchronously (expo-sqlite kv-store telemetry-consent) and are readable before the first render. The default is enabled with no id. Opting out forgets the id. Opting in creates a new one. Verify: telemetry/__tests__/store.test.ts. Source: features/telemetry/store.ts:12-62
  • DATA-077 [MUST] Redux settings, building favourites and course recents persist under persist:root at version 3. Verify: redux/__tests__/migrations.test.ts. Source: source/redux/store.ts:22-28
  • DATA-078 [MUST] Upgrading from redux version 1 or 2 adds Presence to enabled calendars. A list that never existed becomes the full default. An explicitly empty list gains only Presence. Verify: migrations tests. Source: redux/migrations.ts:21-52,93
  • DATA-079 [MUST] Upgrading from redux version ≤2 converts favourites stored as names into St. Olaf favourites. Verify: migrations tests "the campus-scoped favourites migration". Source: migrations.ts:61-88,94
  • DATA-080 [MUST] Building favourites are keyed by campus and name and kept sorted by name. Verify: redux/__tests__/buildings.test.ts. Source: redux/parts/buildings.ts:34-47
  • DATA-081 [MUST] Recent course searches keep the 3 newest, de-duplicated case-insensitively. Recent filters keep the 3 newest by description, ignoring empty ones. Verify: code review. Source: redux/parts/courses.ts:27-52
  • DATA-082 [MUST] Settings saved before a field existed read that field's default (calendar sources, directory view tiles). Verify: redux/parts/__tests__/settings.test.ts. Source: parts/settings.ts:45-78
  • DATA-083 [MUST] Each Zustand-persisted preference survives relaunch under its key with the defaults in the Persisted state table. Verify: ModuleStreamingMediaTests.testSwitchOffHidesOnlyHomesBar (radio); layout-store.test.ts. Source: store files listed
  • DATA-084 [MUST] Calendar filter state saved by v2 (selectedCategory) migrates to {axis:'category', value}. Verify: calendar store test. Source: features/calendar/store.ts:37-42
  • DATA-085 [MUST] News filters saved by v1 are discarded. Messenger prefs from v1 keep only a valid zodiac sign. Verify: news and mess store tests. Source: features/news/store.ts:19-24, features/mess/store.ts:42-46
  • DATA-086 [MUST] A layout screen draws nothing layout-dependent until its saved layout has loaded. Verify: layout-store test "is not hydrated until…". Source: source/lib/layout-store.ts:11-16,45
  • DATA-087 [MUST] The seen Student Work postings list is never overwritten with an empty list. Verify: code review. Source: sis/student-work/store.ts:19-24
  • DATA-088 [MUST] The athletics filter saves only the selected sports, not the available ones. Verify: code review. Source: athletics/store.ts:39-46
  • DATA-089 [MUST] SIS credentials are stored in the Keychain under server stolaf.edu and are read fresh on every use (never cached). Verify: manual. Source: source/lib/login.ts:15,32-51,82-88

Reset

  • DATA-090 [DEV] Reset Everything asks Are you sure you want to clear everything? with Nope! and Reset it!. Verify: manual. Source: features/developer/developer.tsx:12-21
  • DATA-091 [DEV] Confirming the reset empties the in-memory query cache and the saved query cache, clears all AsyncStorage, deletes aao.db and the catalog file, removes the stolaf.edu Keychain item, restores the default app icon, and restarts. Verify: clear-stored-data.test.ts, database/__tests__/client.test.ts. Source: source/lib/refresh.ts:7-23, clear-stored-data.ts:11-32
  • DATA-092 [MUST] A settings change made just before or during a reset does not survive it. Verify: clear-stored-data tests "leaves behind no setting changed…". Source: clear-stored-data.ts:14-19
  • DATA-093 [MUST] The reset restarts the app even if clearing the Keychain or resetting the icon fails. Verify: code review. Source: refresh.ts:10-22
  • DATA-094 [MUST] Dropping a database that was never created, or that fails to close, does not abort the reset. Verify: client.test "does not throw when there is no database to delete", "…nothing was ever opened". Source: client.ts:101-151
  • DATA-095 [DEV] Under --reset-state, AsyncStorage, the SQLite folder and UserDefaults are cleared before JS starts. Verify: plugins/with-app-delegate-customizations.test.ts. Source: plugins/with-app-delegate-customizations.ts:11-28
  • DATA-096 [DEV] The UI-test reset channel clears stored data in place, clears UserDefaults natively, then restarts at the requested route. Verify: every UITestCase subclass (reset between tests). Source: source/lib/uitest-reset.ts:20-40

Telemetry and privacy for network failures

  • DATA-097 [MUST] Each query that finally fails (after retries) sends one api.failure log. Its attributes are the key's first element (or unknown), the kind (http/network/timeout/other) and the status (0 when none). Verify: telemetry/__tests__/query-failures.test.ts. Source: features/telemetry/query-failures.ts:14-51, app/_layout.tsx:92
  • DATA-098 [MUST] No request URL query string or fragment reaches Sentry, and native network breadcrumbs are disabled. Verify: init/__tests__/scrub.test.ts, sentry-options.test.ts. Source: source/init/scrub.ts:19-95, sentry-options.ts:63-71
  • DATA-099 [MUST] Redux state is never attached to Sentry events. Only action types are. Verify: redux/__tests__/sentry-enhancer.test.ts. Source: source/redux/sentry-enhancer.ts:6-14

Developer tools

  • DATA-100 [DEV] The API Tester lists every server route from GET routes, grouped by first path segment. Opening one fetches it fresh (no caching) and shows raw or highlighted JSON. Verify: manual. Source: features/developer/api-test/query.ts:15-30, app/developer/api-test/detail.tsx:25-35
  • DATA-101 [DEV] The BonApp picker accepts digits only and loads food/menu/<id>?cafeId=<id>. With no id it shows No Café ID. Verify: manual. Source: app/developer/bon-app-picker.tsx:13-46

ccc-server contract

  • API-001 [MUST] Every route in the St. Olaf and Carleton tables exists at /v1/<path> on a single-institution deployment, and at /<stolaf|carleton>/v1/<path> in all mode. Verify: ccc-server source/ccc-server/app.test.ts, ccci-stolaf-college/v1/routes.test.ts "${route} is registered". Source: ccc-server:source/ccc-server/app.ts:24-39
  • API-002 [MUST] GET /ping answers pong (HEAD: empty 200) and GET / answers Hello world!. Verify: app.test "all mounts both institutions…". Source: ccc-server:source/ccc-server/helpers.ts:19-25
  • API-003 [MUST] GET /v1/routes returns [{path, displayName, params}] sorted by path, matches case-insensitively, and answers 405 to POST. Verify: app.test "mixed-case route listings…". Source: ccc-server:source/ccci-stolaf-college/index.ts:178-189
  • API-004 [MUST] Cached routes answer from an in-memory cache keyed by full URL (including query string), up to 10 000 entries per institution, for the per-route TTLs in the tables. Verify: ccc-koa/cache.test.ts, app.test "deleting one cache key preserves other query variants". Source: ccc-server:source/ccci-stolaf-college/index.ts:36, ccc-koa/cache.ts:275,392-451
  • API-005 [MUST] Only GET/HEAD 200 responses with a body are cached. 4xx/5xx are never cached. Verify: cache.test "a response fetched but not stored…". Source: ccc-server:source/ccc-koa/cache.ts:541-562
  • API-006 [MUST] Responses carry Cache-Control: public, max-age=<seconds> per route. On a cache hit, max-age counts down to the copy's remaining life. Verify: cache.test "a hit says in Cache-Control how long its copy has left…". Source: ccc-server:source/ccc-koa/ctx-cache-control.ts, cache.ts:421-430
  • API-007 [MUST] Responses carry an ETag. A matching If-None-Match gets 304 with no body, both on fill and on hit. Verify: cache.test "a 304 served from the cache is still a hit". Source: ccc-server:source/ccc-server/app.ts:52-53, ccc-koa/conditional-get.ts, cache.ts:443-446
  • API-008 [SHOULD] Responses carry Cache-Status: ccc-server; … describing hit, stored or collapsed. Verify: cache.test "…says so in Cache-Status". Source: ccc-server:source/ccc-koa/cache.ts:289-292,431-441,491-497
  • API-009 [MUST] Concurrent misses for one URL share one upstream fetch, and its errors. Verify: cache.test "concurrent misses for one key share one upstream fetch", "when the first request fails…". Source: ccc-server:source/ccc-koa/cache.ts:308-377
  • API-010 [MUST] Cached copies return the Link, x-wp-total and x-wp-totalpages headers. Verify: cache.test "a hit gives back the headers…". Source: ccc-server:source/ccc-lib/stored-headers.ts
  • API-011 [MUST] GET /v1/sources returns the published JRD manifest (GH Pages sources.json), cached for 1 day. Verify: routes.test. Source: ccc-server:source/ccci-stolaf-college/v1/sources.ts
  • API-012 [MUST] GET /v1/courses/catalog.db answers 307 to https://stolaf.dev/course-data/catalog-recent.db with max-age=3600, and the redirected host answers If-None-Match. Verify: manual curl. Source: ccc-server:source/ccci-stolaf-college/v1/courses.ts:17-21
  • API-013 [MUST] GET /v1/images/:group/:name serves image/webp for published groups and .webp names. A query string, unknown group or bad name gets 404 without fetching upstream. A missing upstream file gets 404 with no Cache-Control. Upstream failure gets 502. Verify: ccc-lib/images.test.ts. Source: ccc-server:source/ccc-lib/images.ts:11-66
  • API-014 [MUST] GET /v1/orgs/uri/:uri rejects slugs not matching ^[a-z0-9]+(-[a-z0-9]+)*$ with 404 before the cache. An unknown or Demo org gets 404 No student org has the uri <uri>. Verify: routes.test "/orgs/uri/:uri …". Source: ccc-server:source/ccci-stolaf-college/v1/orgs.ts:29-48
  • API-015 [MUST] /v1/orgs, /v1/orgs/categories and /v1/orgs/uri/:uri are cached for 36 hours. Demo orgs are excluded. Verify: routes.test. Source: orgs.ts:11-27
  • API-016 [MUST] /v1/food/menu/:cafeId and /v1/food/cafe/:cafeId read the id from the path and answer 400 cafeId must be one of … for unknown ids. Verify: menu-routes.test.ts. Source: ccc-server:source/ccci-stolaf-college/v1/menu.ts:27-72
  • API-017 [MUST] Named BonApp cafe and menu routes proxy the cafebonappetit.com pages in the table, cached for 1 hour. Verify: menu.test.ts. Source: menu.ts:15-25,83-207
  • API-018 [MUST] /v1/athletics/scores caches for 5 minutes, and for 1 minute while games need frequent refresh. Verify: athletics/index.test.ts. Source: ccc-server:source/ccci-stolaf-college/v1/athletics.ts:12-24
  • API-019 [MUST] /v1/streams/upcoming accepts optional dateFrom/dateTo (ISO date) and sort (default ascending), asks St. Olaf's multimedia API for class=current, and returns starttimes as ISO instants converted from America/Chicago. Verify: routes.test "/streams/upcoming and searching upcoming streams use the same default window". Source: ccc-server:source/ccci-stolaf-college/v1/streams.ts:103-137
  • API-020 [MUST] GH-Pages-backed data routes (contacts, dictionary, faqs, webcams, printing/color-printers, a-to-z/extras, map/categories, orgs/category-styles, student-work/areas, student-work/wages) proxy https://stolaf.dev/AAO-React-Native/<file> and cache for 1 day. spaces/hours, spaces/directory, transit/bus and transit/modes cache for 1 hour. Verify: routes.test. Source: ccc-server:source/ccci-stolaf-college/v1/*.ts
  • API-021 [MUST] /v1/map/geojson, /v1/map/style and /v1/map/style-dark proxy https://stolaf.dev/campus-map-data/… with a 1-hour cache. Carleton's map/geojson proxies https://carls-app.github.io/map-data/map.geojson. Verify: routes.test. Source: ccc-server:source/ccci-stolaf-college/v1/map.ts, ccc-server:source/ccci-carleton-college/v1/map.ts:12-17
  • API-022 [MUST] Carleton /v1/spaces/hours proxies https://carls-app.github.io/carls/building-hours.json with a 1-hour cache and the same schema as St. Olaf's. Verify: app.test "endpoints can add dotted and major versions…". Source: ccc-server:source/ccci-carleton-college/v1/hours.ts
  • API-023 [MUST] /v1/student-work/units returns a map from posting id to unit ("other" when unlisted), cached for 1 hour. Verify: student-work/units.test.ts. Source: ccc-server:source/ccci-stolaf-college/v1/student-work.ts:28-38
  • API-024 [MUST] /v1/calendar/named/krlx-schedule proxies KRLX's Google Calendar, and ksto-schedule expands the published weekly KSTO schedule. Both cache for 1 minute. Verify: calendar.test.ts, weekly-schedule.test.ts. Source: ccc-server:source/ccci-stolaf-college/v1/calendar.ts:53-68
  • API-025 [MUST] The Messenger WordPress proxy refuses unknown resources (404) and unknown, duplicate or malformed parameters (400) without contacting the paper. Verify: mess.test.ts "rulesFor…". Source: ccc-server:source/ccci-stolaf-college/v1/mess.ts:116-136
  • API-026 [MUST] During a Messenger outage (timeout >7 s, 5xx, 403/408/429, non-JSON) the proxy serves the last good copy (up to 7 days old) with a 1-minute TTL, or 502 when it has none. Verify: mess.test "serves the last good copy on …", "is a 502 on an outage with no copy". Source: mess.ts:190-318
  • API-027 [MUST] Paged Messenger lists carry an RFC 8288 Link header (first, prev, next, last) and WordPress total headers. Verify: mess.test "paginationLinks". Source: mess.ts:153-170,238-250
  • API-028 [MUST] Retired routes answer 200 with a one-item notice payload (title Temporarily unavailable, No longer updated or Deprecated endpoint, linking discussion #564), not 404. Verify: calendar/deprecated.test.ts. Source: ccc-server:source/ccci-stolaf-college/v1/deprecated.ts, feeds/wp-json.ts:98-110
  • API-029 [MUST] Bad requests answer with the status and a plain-text message, for example 400 ?url is required. Verify: routes.test. Source: ccc-server:source/ccci-stolaf-college/v1/news.ts:14-25, calendar.ts:17-29
  • API-030 [SHOULD] Upstream requests identify as ccc-server/0.2.0 and time out at 30 s per attempt and 60 s in total. Verify: ccc-lib/http.test.ts. Source: ccc-server:source/ccc-lib/http.ts:4,58-64
  • API-031 [SHOULD] DELETE /_cache[?key=…] evicts entries (all or the named keys) and answers 204 with X-Cache-Deleted. GET /_cache lists keys and seconds left. Verify: app.test "all keeps cached responses separate…". Source: ccc-server:source/ccc-server/helpers.ts:27-52
  • API-032 [SHOULD] A client hanging up mid-response is not reported as a server error. Verify: client-abort.test.ts. Source: ccc-server:source/ccc-koa/client-abort.ts
  • API-033 [DEV] With ADVERTISE_MDNS=1 the server advertises _ccc-server._tcp with TXT institution and path (/v1/, or / in all mode). Verify: manual. Source: ccc-server:source/ccc-server/server.ts:54-155
  • API-034 [SHOULD] Carleton dictionary, faqs and webcams are not response-cached (no ctx.cached) but still send max-age=3600. Verify: code review. Source: ccc-server:source/ccci-carleton-college/v1/dictionary.ts:6-9, faqs.ts, webcams.ts

Existing test evidence

Test What it proves Covers
source/init/__tests__/tanstack-query.test.ts (shouldDehydrateQuery; meta persist; sources manifest; cache saved by another build) dehydration rules, page trimming, persistAfterFailure, buster DATA-021, 028–032
source/lib/__tests__/clear-stored-data.test.ts redux writes cannot survive a clear; AsyncStorage empty after DATA-091, 092
source/lib/__tests__/layout-store.test.ts layout defaults, remembering, hydrated DATA-083, 086
source/lib/__tests__/remote-images.test.ts, published-images.test.ts image URL against the root; prefetch de-dup and failure; groups match published DATA-001, 010, 011
source/database/__tests__/client.test.ts dropDatabase closes, deletes the catalog, tolerates missing or failing; bad catalog discarded DATA-072, 091, 094
source/database/schema.test.ts fingerprint rebuild DATA-070
source/database/courses/__tests__/refresh.test.ts, catalog-file.test.ts conditional download, rejection, swap DATA-066–068, 073
source/database/calendar/write.test.ts retention DATA-074
source/redux/__tests__/migrations.test.ts redux v2/v3 migrations DATA-077–079
source/redux/__tests__/buildings.test.ts campus-keyed favourites, sorting DATA-080
source/redux/parts/__tests__/settings.test.ts calendar source defaults, directory view fallback DATA-082
source/redux/__tests__/sentry-enhancer.test.ts no state to Sentry DATA-099
modules/data-sources/__tests__/fetch-source.test.ts relative vs absolute, timeout, abort, error mapping, text format DATA-012–016
modules/data-sources/__tests__/resolve.test.ts bundled manifest contents; rules 1–3; offline fallback DATA-019, 020, 022
modules/data-sources/__tests__/types.test.ts JRD validation DATA-023
modules/data-sources/__tests__/parse-each.test.ts tolerant list parsing DATA-017
modules/storage/__tests__/storage.test.ts getters' defaults DATA-075 (partially)
modules/notice/__tests__/list-state.test.ts, NoticeView.test.tsx list, error, offline and loading decision; Try Again; describeError DATA-037–039
source/features/telemetry/__tests__/query-failures.test.ts api.failure mapping and subscription DATA-097
source/features/telemetry/__tests__/store.test.ts consent store sync storage and ID lifecycle DATA-076
source/init/__tests__/scrub.test.ts, sentry-options.test.ts URL scrubbing; breadcrumb options DATA-098
source/features/developer/__tests__/use-server-discovery.test.ts mDNS URL building, removal, cleanup DATA-008, 009
plugins/with-app-delegate-customizations.test.ts --reset-state clears SQLite and AsyncStorage DATA-095
uitests/UITestCase.swift (reset channel used by every test) in-place reset between tests DATA-096
uitests/ModuleStreamingMediaTests.swift testSwitchOffHidesOnlyHomesBar a persisted radio preference survives relaunch DATA-083
uitests/ModuleMenusTests, ModuleHoursTests, ModuleMapTests, ModuleDirectoryTests, ModuleStudentOrgsTests, ModuleStudentWorkTests, ModuleTransitTests, ModuleCampusDictionaryTests, ModuleCalendarTests, ModuleNewsTests, ModuleStreamingMediaTests, ModuleStoPrintTests the screens render the data these requests feed (fixtures under --uitesting, so the request contract itself is not exercised) DATA-044–065 (screen side only)
uitests/Chaos/ChaosTests.swift the app survives injected network faults DATA-036–039 (robustness)
ccc-server source/ccc-server/app.test.ts mounting, ping, routes, _cache, per-institution caches, versions API-001–003, 022, 031
ccc-server source/ccc-koa/cache.test.ts sharing, Cache-Status, stored headers, max-age countdown, 304 API-004–010
ccc-server ccci-stolaf-college/v1/routes.test.ts registration, orgs/uri, streams params and paging, html-to-md API-011, 014, 015, 019, 020, 029
ccc-server ccci-stolaf-college/v1/menu-routes.test.ts, menu.test.ts cafeId from path; unknown ids refused API-016, 017
ccc-server ccci-stolaf-college/v1/mess.test.ts Messenger allow-list, paging links, last-good-copy API-025–027
ccc-server ccc-lib/images.test.ts image proxy API-013
ccc-server ccc-lib/http.test.ts upstream outcome metrics, timeouts API-030
ccc-server ccc-koa/client-abort.test.ts hang-ups ignored API-032
ccc-server ccci-carleton-college/v1/routes.test.ts Carleton html-to-md, convos id API-001

No automated coverage:

  • DATA-002–007 (server override UI and behaviour)
  • DATA-018, 024, 025 (manifest timing; radio sources)
  • DATA-026, 027, 033–036 (gcTime, restore at launch, maxAge, focus and online refetch)
  • DATA-040–043 (exact offline copy is not asserted by any UI test)
  • DATA-045, 048, 053, 056 (request shape), 057, 061, 062, 064, 065, 069, 071
  • DATA-081, 087–090, 093, 100, 101
  • API-012, 018 (TTL switch is tested only at the library level), 023, 024, 028 (partly), 033, 034

Open questions

  1. Telemetry consent survives "Reset Everything". The consent store uses the expo-sqlite kv-store, which neither AsyncStorage.clear() nor dropDatabase() (which deletes only aao.db) removes. --reset-state does remove it, because it deletes the whole SQLite folder. Is the survival intended?
  2. The client is undefined until the async server-address read finishes (source/init/api.ts:11-21). A query that runs before then would throw Cannot read properties of undefined. The persisted-cache restore and redux gate probably delay first renders enough, but nothing guarantees it.
  3. The query-cache persister key and maxAge are library defaults (REACT_QUERY_OFFLINE_CACHE, 24 h). node_modules was not available to confirm them. Because the maxAge equals gcTime, a cache last written more than 24 h ago is discarded entirely, so "offline after a weekend" shows no saved menus. Is that intended?
  4. ATS vs mDNS: discovered local servers are http://<LAN IP> URLs, but the ATS exceptions cover only localhost. Saving one may fail every request unless the debug build adds NSAllowsLocalNetworking.
  5. mDNS in all mode: the server advertises path=/, so the app would use http://host:port/ and request /sources, which 404s; all mode needs /stolaf/v1/.
  6. Stale app comment: source/features/menus/query.ts:42-44 says the server reads cafeId from the query string. The server reads the path, and ?cafeId= only makes a separate cache key. The server's CAFE_ID_TO_URL also lacks The Cave and Schulze ids.
  7. Dead code: these source/lib/storage.ts helpers have no callers: homescreen order and disabled views, settings:ackd, buildings:favorited, sis:course-data:*, courses:recent-*. A native rewrite should not migrate those keys unless older installs still need them. Redux owns favourites and recents now.
  8. Many ccc-server routes are unused by this app:
    • St. Olaf: food/item, kings-room, the-cave, calendar/google/ics/northfield/stolaf/oleville, a-to-z, directory/majors, tools/help, jobs, map, news/* (all named feeds), streams/archived, streams/search, reports/stav, util/html-to-md.
    • Carleton: everything except spaces/hours and map/geojson.
    • news/named/oleville is in the manifest but no screen uses it.
    • These exist for older builds or other clients.
  9. Double HTTP caching: iOS URLCache.shared (20 MiB disk) may serve ccc-server GETs within max-age without a network round-trip, on top of React Query. If so, a pull-to-refresh within, say, a 1-day TTL could return the cached copy. This was not verified on device.
  10. Error copy is raw: LoadErrorView shows error.message, for example News fetch failed: 500 or ky's Request failed with status code … text. A rewrite matching "exact strings" would need ky's message format (inferred).
  11. Retry stacking: ky's own retries (inferred: 2) run inside each React Query retry (3), so one failing GET through client can make up to 12 attempts before api.failure is reported.
  12. The Carleton dictionary, faqs and webcams routes do not use the response cache, so every request hits GitHub Pages. This looks like an oversight; the app does not call them.

← 09. Streaming media: radio, webcams, schedules, movie · Spec index

Clone this wiki locally