the everything app :D cedarplan.dunkirk.sh

Files
README.md

the-cedarville-app #

the everything app ๐Ÿ˜„

course planning #

Colleague Self-Service gates every endpoint behind a student session, so the data has to be fetched from inside a browser that already has one. That is the extension's entire job. Everything else is a static page.

bun install
bun run dev        # builds both, serves the planner on :5173

Then turn on Developer mode at chrome://extensions, load dist/ unpacked, sign in to Self-Service in another tab, and open the planner. The extension has no icon, so it is the grey puzzle piece in the toolbar.

The first student to open a term crawls it, which takes a minute or so and fills the shared cache for everybody after them. Nothing is cached until somebody does; there is no catalog in this repo to start from.

Optionally, bun run companion keeps a copy of your capture on your own machine for the scripts under scripts/ to read. Nothing starts it for you.

The extension is built against one origin, because the manifest has to name it literally โ€” APP_ORIGIN=https://plan.example.edu bun run build for a hosted copy, localhost otherwise. Anyone running this builds their own; there is nothing to install from a store.

Reading is the whole of it until you press send to Colleague on the plan tab, which is the one thing here that writes to the registrar's system. It shows the entire diff first and writes nothing until you confirm it. See writing a plan back.

shape #

src/client.ts        Self-Service endpoints + the antiforgery handshake
src/content.ts       runs on selfservice.cedarville.edu; fetches, nothing else
src/background.ts    the bridge; only whitelisted origins may call it
src/types.ts         raw Colleague shapes, shared by both halves
src/crawl.ts         the paging loop, over whoever has a session
src/requirements.ts  Ellucian's 40-field Group as a tagged union
src/merge.ts         which course satisfies a requirement in both majors
src/schedule.ts      meeting times, seat counts, and date-aware conflicts
src/timetable.ts     which section of each, so that nothing collides
src/prereqs.ts       what a course needs, and what needs it
src/planner.ts       which term each requirement lands in
src/catalog.ts        the one shape that is public rather than personal
src/server/colleague.ts  guest client, for wherever a guest endpoint is left
src/server/crawler.ts    the crawl loop bound to a guest session and the store
src/server/ingest.ts     what it takes to trust somebody else's crawl
src/server/store.ts      SQLite cache of the section catalog
src/client/          the planner: no framework, one CSS file, mount/destroy views
src/client/planning.ts   one projection, shared by every tab that reads one

Four tabs, one per question a student actually asks. build is what is left to decide and what each choice costs. plan is when it all happens, drawn as a graph or listed by term โ€” one computation, two renderings. semester takes one term off that plan and lays it on the clock, already arranged: one section per course, nothing overlapping, and pins where the student has chosen. record is what the registrar holds.

There were six. map and plan turned out to be the same projection rendered two ways, and overlap could only compare two enrolments, which a second major recorded against the first one's program is not.

The split is by change rate. Auth bridging is stable and security-sensitive; the planner changes every time we learn something new about Colleague. The two halves share types.ts and crawl.ts.

Two kinds of data, and only one of them is yours. Section times, seats and instructors are identical for every student, so they are cached in SQLite on the server and one student's crawl spares everyone else's. An evaluation is a student record and never leaves the machine it was fetched on. There is no account system because there is nothing here to attach to a person.

The catalog half of that used to be the server's own work. Cedarville put the public course search behind SSO, so the crawl now runs in whichever student's browser gets there first and is offered back to the cache. See crawled by whoever is signed in.

deploying without deploying anyone's transcript #

That promise used to be kept by accident: the planner only ran on localhost, so a capture had nowhere else to go. Hosting the page would have broken it quietly, which is the worst way for a promise like that to break.

So the halves are split by what they may hold, not by where they run.

catalog server   public, deployable   sections, courses, rules
companion        127.0.0.1 only       one capture, on your machine
extension        the only bridge      fetches, then hands over

APP_ORIGIN=https://plan.example.edu bun run build writes the manifest with that origin allowed to talk to the extension, alongside localhost so a development build keeps working. The extension posts each capture to a companion on 127.0.0.1:7749, which runs only when you start it with bun run companion and by nothing else. It accepts POST /capture and only from chrome-extension://<the pinned id>; a page cannot set its own Origin, so no other tab can reach it. There is no way to read a capture back out over the port: it takes, it never gives.

Nothing is lost when no companion runs. The post fails, the planner carries on in the browser, and the scripts under scripts/ say which file they were looking for.

The same channel carries what the student decided. "Copy my plan" sends the pins, tracks and credit load through the extension to POST /picks, so the scripts answer about the degree you chose rather than the cheapest one that fits, and say which of the two they did.

This is the only way a transcript reaches a disk, and that is deliberate. There was briefly a second: a development route on the catalog server that wrote whatever it was posted into .data/, guarded by NODE_ENV and by the hostname the request arrived on. A hostname is a header the client sends, so the guard asked the attacker whether they were an attacker โ€” and hosting the planner anywhere meant shipping it. The route is gone. The server writes its SQLite catalog and nothing else, which is the whole of what it is for.

Chrome will not install an extension from a web page: a .crx offered over HTTP has been refused since version 75, and dragging one in is refused too. So bun run build also writes public/cedarville-bridge.zip, the planner serves /install.html beside it, and a student unzips the folder and loads it unpacked. The archive is stored rather than deflated and dated to the zip epoch, so a rebuild from the same source is the same bytes and not a fresh download for everybody who already has it.

The page is built knowing which extension it was built against, and asks the installed one for its version at startup. The two being different ages is the commonest failure in this application โ€” an extension updates, a Self-Service tab keeps the content script it loaded this morning, and the symptom is a reply in a shape the page stopped expecting โ€” so it is said out loud rather than left to be diagnosed.

CEDARVILLE_CAPTURE   where a capture is kept (default: XDG data dir)
CEDARVILLE_PORT      the companion's port
CEDARVILLE_COMPANION 0 to decline the listener entirely
CATALOG_DB           the catalog cache (default: .data/catalog.sqlite)
CRAWL                "off" to skip the server's own boot crawl
PORT                 the planner's port (default: 5173)
HOST                 interface to bind (default: 127.0.0.1; 0.0.0.0 in a
                     container, behind nothing)
APP_ORIGIN           build-time: the origin the extension may talk to

what it refuses to guess #

Colleague states some requirements as opaque server-side rules (DABIOL25, "one laboratory course from the biological sciences") and some as department filters that no evaluation endpoint resolves. Those are reported as unresolved rather than matched loosely, because a planner padded with maybes is worse than a shorter honest one. Schools also cap credits shared between two majors, and that policy lives in the academic catalog, not the API: pass sharedCreditCap to merge to have it checked.

the second major nobody evaluates #

A student in two majors has one enrolment. BS.CYOPR lists both cyber operations and computer science under Majors, ships requirement blocks for cyber operations alone, and says nothing about the omission โ€” so a planner reading the response at face value quietly plans half a degree. The headings give it away: every block is named " Major Requirements", so a credential with no block of its own is one Colleague never answered. Those are evaluated separately by program code and reported as enrolled, because the registrar has the student in them.

The same asymmetry runs through the transcript. Each evaluation reports only the credit its own requirements consumed, so anything reading history reads every tree, or it offers to buy a course the second major already paid for.

what blocks what #

Colleague states requisites as a rule id it never expands, but it also ships the registrar's own wording: "Take CS-1220", plus a line saying whether that must come before, alongside, or is merely recommended. That text is the only machine-readable prerequisite data there is, so src/prereqs.ts parses it.

Of 373 courses with requisites in Fall 2026, 325 parse cleanly. The other 48 say things like "junior status", "permission of instructor", or "acceptance into the PA program". Those gate a course just as hard, so they report as unknown rather than open โ€” telling a student they are eligible when they are not is the one failure worth engineering against.

The graph gives three things worth planning around: whether you can take a course now, which courses it would unlock, and how deep its chain runs. A course gating eleven others belongs earlier in a degree than one gating none, and the plan places it accordingly.

which section to be in #

The planner answers "what am I taking in the spring", which leaves a second problem with the same shape and a much smaller search space. Each of those courses runs in two or six or eleven sections, at most one of which you can be in, and no two of them may put you in two rooms at once. Students do this on paper, badly, once a semester.

src/timetable.ts solves it as the constraint problem it is: depth-first over the courses with the fewest places to go first, pruning on conflict, which searches a five-course term exhaustively in a few thousand steps. A greedy pass that takes the nicest-looking section for the first course routinely paints the last one into a corner, and the student cannot see why.

Which conflict-free week is better is a judgement, so it is stated rather than buried, and compared term by term rather than weighted:

seats   a section you cannot get into is worth less than one you can
days    a day with nothing on it is worth more than a tidy hour
gaps    an hour between classes is an hour spent waiting
shape   and then whatever the student asked for

Pins come first and are never weighed at all. A student who has decided to be in the eight o'clock section has decided; the arranger works around it and reports what it cost โ€” including a course it can no longer place, which is the answer a student needs rather than a silent omission.

Seat counts are refreshed live where the extension can reach Self-Service, and the term is arranged again when they land: "prefer a section you can get into" is only true if the arranger knows which those are.

The interface follows from that. The week is the object, drawn the moment the tab opens, and the course list beside it is one line each โ€” a list of every section's days, room, instructor and seat count is a timetable written out as prose, and the timetable is right there. A course opens to its sections as a radio group, hovering one draws it on the week so you can see where it would fall, and the first option in every group hands the choice back to the arranger. So "whose decision was this" is something you can see and change rather than infer.

A draft in between asked for a drag onto the week. It read well written down and badly in the hand: a section's time is fixed, so the only honest places to drop it were the few it could already go.

what an advisor changed by hand #

A degree audit is mostly Colleague talking to itself. The exception is a modification: a human wrote "8/20/26: EGGN-1110 permitted to replace EGGN-1910." and hung it on one requirement group. Colleague applies the credit and then goes on listing the replaced course, so a planner reading the course list alone schedules a semester of work the registrar already excused.

Those messages are parsed, and only where they were granted. A substitution is made against a requirement rather than against a student, so a second major can still be asking for the course the first one dropped โ€” which is a real question for an advisor, and is reported as one rather than assumed either way. The replacement must also be on the transcript: a permission granted is not a course taken.

A message that does not parse is shown verbatim. An advisor's note is the one line of an audit a person wrote on purpose, and failing to read it is no reason to hide it.

the gates that are only prose #

Of 906 requisites in the catalog, 82 say something no parser should pretend to understand: "acceptance into the PA program", "permission of instructor", "undergraduate course or equivalent competency in microeconomics". Those stay unknown, which is the honest answer.

One kind is worth reading, though, because it decides when rather than whether. 58 courses gate on class standing, and 261 name a prerequisite in their description that no requisite record carries โ€” EGGN-4010 Senior Seminar has no requisites at all, and the whole of its condition is the sentence "Prerequisite: senior status in engineering". Read literally, it is open to a freshman, and the plan put it in one. Standing is now parsed out of that prose and checked against the credits a student will hold when the term starts, against the catalog's own table: sophomore at 31 hours, junior at 61, senior at 91. That table is printed in the catalog and reachable by no API, so it lives in STANDING_CREDITS where one edit follows a policy change.

The rest of those descriptions were left alone on purpose: only ten of the 261 name a course code, and the other 251 are admissions and permissions that no amount of parsing turns into a date.

Thirty-one courses do not even get a sentence. HON-4910 Honors Senior Colloquium I has no description, no requisite and no rule; the only thing in Colleague saying it is a senior course is the word Sr in its title. Read literally it is open to a freshman, and the plan put the honours colloquium in a sophomore summer. A senior-sounding title now implies senior standing, and parsed prose still wins wherever there is any.

two traps in the timetable #

Meeting times arrive as UTC pinned to an arbitrary reference date: an 11:00 AM class is 2026-08-11T15:00:00+00:00. Reading the hour out of that string puts every class four hours late. Prefer StartTimeDisplay, which is what the registrar shows and carries no timezone; convert the instant only when it is the only source.

conflicts are date-aware #

A 16-week term routinely contains 8-week sessions, so two sections can share a weekday and an hour and never coexist. Every meeting carries its own date range and every comparison uses it; a day-and-time check alone invents clashes and makes half the catalog look unschedulable.

the catalog used to need no login #

It did, once. Self-Service gated /Student/Student/Courses/* and served /Student/Courses/* to anyone, because that is what the signed-out search page used. The server crawled the catalog itself, anonymously, and one pass served every student.

Cedarville moved the whole catalog behind SSO. The guest search page now bounces to the login form, so that crawl is gone and no amount of retrying brings it back. GuestColleague says so in as many words rather than blaming a missing antiforgery token the page was never going to render.

What is left with a session is a student's browser. So the crawl moved there.

crawled by whoever is signed in #

The loop did not have to be rewritten, because it never knew who it was talking to. Every crawl asks one question of one object:

interface Searcher {
  search(criteria: SearchCriteria): Promise<SearchPage>
}

GuestColleague satisfies it, for as long as there is a guest endpoint anywhere. So does SelfService, which is the authenticated twin: the same body and the same paging, one /Student more in the path. The planner wraps that in a Searcher whose search hops through the extension, and src/crawl.ts runs over either one without knowing the difference.

One page per message, not one term, so the paging, the delay between requests and the stop button all stay on the page where the student can see them. A term is about sixty pages in SectionListing view, plus half as many again in CatalogListing for the requisite text.

ingesting somebody else's crawl #

Crawling per student would undo the thing the server-side crawl was for: sixty pages per student per term, against the registrar, every time. So a crawl is offered back.

POST /catalog/:term/ingest

Whoever opens a term first pays for it, and everybody after them reads the cache. The property the old design got by construction is bought back with three guards, because a crawl the server performed was true and a crawl posted to the server is a claim that every other student will read.

shape         every section checked field by field, and one bad section
              refuses the batch. A skipped section is indistinguishable
              from a cancelled one.

completeness  the client says whether it reached the last page, and only a
              complete crawl may replace a term. `store.replace` deletes
              what the crawl did not see, so a student closing the tab
              halfway would cancel half a term.

agreement     a crawl replacing a term must *be* that term: it may add,
              it may lose up to a fifth, and the rest of the section ids
              have to still be there. Counting sections instead let a
              hundred fabrications stand in for a hundred real ones,
              because a hundred is a hundred.

real courses  a section's course must appear in the list of every course
              the school offers, which arrives by its own crawl with its
              own guards. So a crawl may lie about when a real course
              meets; it may not invent the course. Checked by code rather
              than by id, because about 1% of codes carry two records and
              a section may reference either โ€” checked by id, one honest
              section of the spring's 1,728 refused the whole crawl.

sane numbers  credits and seat counts within ranges a registrar could
              mean. Seats left may be negative, because an override
              really does over-enrol a section.

Sections must also carry the term they were posted under, or a Fall crawl could be written into the Spring cache.

There is deliberately no identity here. There is no account system and nothing to attach one to, so every guard is about the claim rather than the claimant. What that leaves is bounded: a student with a real session can post a plausible lie about when a real course meets, in a term somebody has already crawled, for up to a fifth of its sections at a time โ€” and the server logs what each crawl kept, added and lost, so a run of odd-looking ones is visible rather than silent. What they cannot do is empty the catalog, swap a term for an invented one, or add a course that does not exist.

Corroboration would close the rest of it, and cannot: requiring two submissions to agree costs an attacker one more request, since there is no identity to count submissions from. The first crawl of a fresh term is likewise trusted, there being nothing yet to hold it to. Both are worth saying plainly rather than implying a guarantee that is not there.

POST /catalog/:term/refresh still exists and still crawls server-side, for wherever a guest endpoint is open. It now refuses a term the catalog has never heard of: terms are a closed set the registrar publishes, and accepting any string meant one caller could start unlimited outbound crawls by inventing spellings.

The extension is needed for two things now: your own program evaluation, which was always personal, and the catalog, which did not used to be.

one course list, any major #

Colleague encodes choice with two counts: a requirement may need only MinSubrequirements of its subrequirements, and a subrequirement only MinGroups of its groups. That is how tracks, concentrations and "satisfy the global-awareness rule any one of six ways" are all expressed.

Choose-from groups are then solved together rather than one at a time, because Colleague lets a single course count toward several requirements at once: three of one student's completed courses are applied to two groups each, MATH-1705 satisfying both the general-education quantitative slot and the major's cognates. Picking per group in isolation buys a second course for a requirement already met.

That is weighted greedy set cover โ€” take whichever course closes the most remaining credit per credit spent. Exact set cover is NP-hard, the greedy bound is comfortably good enough for a few dozen requirements, and unlike an exact solver its choices stay explainable.

coursesNeeded reads those counts and returns the cheapest satisfying path, which is what makes the planner work for any major rather than the one it was written against. Before it, every alternative looked mandatory โ€” a plan could demand Greek and Spanish, and the only way to get sensible output was a hardcoded list of the CS major's track names.

Groups it cannot enumerate โ€” a Colleague rule, or a filter over attributes the evaluation does not carry โ€” come back separately as unenumerable, each carrying the ids needed to expand it. Hand the expansions back through NeedOptions.resolved and they join the same cover as everything else, which is how a course bought for one requirement ends up paying for a rule-based one too. Two passes: name the groups, resolve them, solve once.

what exists vs what is offered #

Two crawls, and they answer different questions. The per-term crawl says what runs when; a term-less CatalogListing crawl says what the school teaches at all, stored under the ALL sentinel.

They cannot be one crawl, because a prerequisite is routinely a course nobody is teaching this year. EGEE-2010 roots a four-course engineering chain and appears in neither cached term. Built from term-scoped data alone the graph held 1010 nodes and was missing 99 of the 277 courses named as prerequisites โ€” 36% โ€” silently reporting depth 1 where the truth was 5.

With the full catalog: 2027 nodes, 27 missing, and the chains measure right.

The 27 that remain are not gaps in the crawl. Each entering class is locked to a catalog year, and courses are retired and renumbered between them, so requisite text outlives the catalog it was written under. MATH-1720 was Calculus II, is named by five courses, and no longer exists โ€” today it is MATH-1715. A few entries also carry transposed subject codes (CLUM for CLMU, EDMU for MUED) or name subjects that are gone.

Where Colleague does track the drift, it is worth using. EquatedCourseIds declares which courses count as each other โ€” ENGR-1910 is now EGCP-1010, COM-1410 is now THTR-1410 โ€” and it is published on section records, not on the catalog view. Harvesting it yields 323 linked codes, so a transcript carrying an older catalog's codes still matches modern requirements.

It resolves only 3 of the 27 phantoms, and the reason is worth stating. Cedarville reworked its calculus sequence: Calculus I and II went from 5 credits to 4 and were renumbered, and Calculus III was split in two.

retired                    current
MATH-1710  Calc I    5cr   MATH-1705  Calculus I     4cr
MATH-1720  Calc II   5cr   MATH-1715  Calculus II    4cr
MATH-2710  Calc III  5cr   MATH-2705  Calculus IIIA  3cr
                           MATH-2715  Calculus IIIB  3cr

Different credit hours mean different courses, and Colleague equates none of them. Inferring equivalence from adjacent numbers would tell a student a requirement is met when it is not, so buildEquivalences only ever reads what the registrar declared.

The transition is half-finished in the data. Six requisites were updated to accept either ("Take MATH-2705 or MATH-2710"); nine still name only the retired course, including MATH-2210 Logic & Methods of Proof, which is a mathematics core requirement gated on a course nobody can enrol in. Those report unknown with an explanation rather than blocking forever โ€” and while a reachable prerequisite is still outstanding, "blocked on MATH-2705" stays the answer, because it is the one a student can act on.

The same drift shows up a second way: about 1% of course codes carry two records, a course being retired beside its replacement, both live during the transition. Colleague tells them apart by id and picks per the student's catalog year. Requisite text only ever names a code, so a graph keyed by code has to choose one โ€” dedupeByCode prefers the record actually being taught rather than whichever the crawl saw last.

That matters because a prerequisite naming a course nobody can take marks its dependents permanently unreachable โ€” 17 courses were blocked forever on phantoms. eligibility takes an optional exists check and reports those as unknown with an explanation, rather than as a wall. It does not guess that MATH-1720 means MATH-1715: the numbers are close, the meaning is not certain, and quietly substituting one for the other is how a planner tells a student something false.

expanding a rule #

An evaluation never says which courses satisfy DABIOL25, but the course search does: PostSearchCriteria accepts a requirement / subrequirement / group triple and Colleague evaluates its own rule. That is what the "Search for courses" button in the degree audit calls.

POST /rules/resolve   [{requirement, subrequirement, group}, โ€ฆ]

No session used to be needed here: the triple names a place in the catalog, not a student, so the server resolved it anonymously and cached the answer in SQLite, shared by everyone. The caching and the sharing still hold. The anonymous part does not, now that the search is behind SSO, so the server answers from cache and a miss has to wait for somebody signed in to fill it. DABIOL25 is five biology labs; the history elective is forty-seven courses.

One kind is deliberately not expanded. A filter naming no subject and no department ("32 hours of upper-division work") matches most of the catalog and is satisfied incidentally by the courses a degree already requires. Expanding one and filling it cheapest-first produces thirty-two 1-credit independent studies: arithmetically valid, obvious nonsense. Those are flagged bucket and reported rather than scheduled.

planning #

src/planner.ts answers the question a credit total cannot: when. Credits set a floor, but a four-deep prerequisite chain cannot be compressed by taking a heavier load, and a spring-only course cannot move to autumn.

bun scripts/plan-doc.ts     # writes .data/plan.md

It reads whichever capture the companion wrote, so a capture that landed in the XDG data directory is found as readily as one in .data/.

Two things a prerequisite cannot say, and the planner reads both out of the catalog rather than from a table anyone maintains. Class standing gates 58 courses and appears in no requisite record โ€” it is a sentence of description ("senior status in engineering"), or nothing but the word Sr in a title. Sequences run back to back: CY-4820 says "Continuation of CY-4810" in its description, HON-4920 is paired to HON-4910 by nothing but the numeral in its title, and Making of the Modern Mind says it only by being one autumn course and one spring course of the same name. A prerequisite means "later", which is how a capstone ends up split across two academic years.

What it still does not model, each of which can move a date: the unpublished spring catalog, and shared-credit caps between programs. The generated doc lists them at the bottom rather than implying a precision it does not have.

writing a plan back #

Everything above reads. One thing writes, and it writes to the registrar's system where an advisor will see it, so it is worth stating exactly.

Self-Service's own Plan & Schedule page drives five endpoints, and its script bundle names their arguments; nothing here was guessed at.

AddCourse     { courseId, termId, credits, degreePlan }
UpdateCourse  { courseId, oldTerm, newTerm, degreePlan }
RemoveCourse  { removeCourseId, removeCourseTermId, removeCourseSectionId, degreePlan }
AddTerm       { addTermId, degreePlan }
RemoveTerm    { removeTermId, degreePlan }

Each carries the whole plan and returns the updated copy โ€” Ellucian's concurrency check, since the DTO holds a Version and a stale one is refused. So the writes run in sequence, each fed what the last one handed back. RegisterSections sits on the same controller and is deliberately wired to nothing: planning a course and registering for it are different promises, and only one of them is the planner's to make.

send to Colleague shows the whole diff first and writes nothing until it is confirmed. The plan on the screen is the plan, and Colleague's copy is brought into line with it. Two things are never touched, and between them they are the whole safety of it:

  • a course carrying a section, which is a registration decision rather than a plan
  • anything in a term already under way, or behind it โ€” a course in progress sits on the degree plan too, and a projection that starts next spring never mentions it

Everything else is arrangement. If there is a course you want that the projection does not know about, add it to the plan first; otherwise the next sync withdraws it.

dumping a session (local only) #

Some of Colleague is genuinely personal and needs your own login: the program list, and any what-if evaluation. For poking at those from a shell rather than clicking through the extension:

# Chrome devtools -> Network -> any XHR on selfservice.cedarville.edu
# -> right click -> Copy -> Copy as cURL
pbpaste | bun scripts/session.ts save
bun scripts/session.ts check

bun scripts/as-me.ts programs minor
bun scripts/as-me.ts evaluate BS.CMPEG

document.cookie will not do: .ASPXAUTH is HttpOnly, so the cookie that matters is invisible to page scripts.

Two things cost an hour to learn, so they are worth writing down. Cookies are kept by prefix, because ASP.NET splits an oversized cookie into a base plus numbered chunks and an exact-match filter keeps the chunks while dropping the base. And a GET must not send X-Requested-With: it makes Colleague treat the request as AJAX, demand an antiforgery token, and answer 400 with a message indistinguishable from a dead session. POSTs do need a token, scraped from an authenticated page so it pairs with the cookie already held.

This is a development convenience and deliberately not part of the app. That cookie is the whole student account, including the ability to register and drop classes, so it is filtered down to the four cookies Self-Service actually authenticates with, written to .data/session.json at mode 0600, and never sent anywhere. The server holds only the public catalog and the extension holds no credentials at all; neither of them ever reads this file.

Tests run with test/setup.ts preloaded, which pins CATALOG_DB to :memory:. Without it a stray import of serve.ts would open the real catalog database from a test.

The canonical repo for this is hosted on tangled over at https://tangled.org/dunkirk.sh/the-cedarville-app

ยฉ 2026-present Kieran Klukas