T12: Marast provider + API exploration #11

Merged
dzenda merged 78 commits from t12-marast into t11-grades 2026-11-14 17:50:23 +01:00
Owner

T12: Marast provider (fit.marast.assignments, fit.marast.courses) + API exploration

Stacked on #10 (t11-grades) → … → #1. Merge those first.

Exploring the API (the "+ explore API" part of T12)

  • The site is a Rails app. GET /api/v1 itself returns 404, which made the SPEC base URL look wrong. Probing with a bogus Bearer token and Accept: application/json showed real routes answering 401 Unauthenticated (/courses, /quizzes, /users/me, /notifications).
  • Found a public Swagger doc at https://marast.fit.cvut.cz/api/v1/swagger_doc. Snapshot: docs/marast-swagger.json. It lists every endpoint: quizzes, courses, lectures, problems, topics, notifications, roles, users/me, notes and comments. It has no response schemas.
  • Swagger lists Api-Key/Api-User/Api-Pass header auth. The FIT scope urn:ctu:oauth:marast:personal suggests Bearer works too; to verify on the first real login.
  • The source repo (gitlab.fit.cvut.cz/marast/marast) is private, so field names are unknown. Everything is recorded in docs/apis.md.

What

  • fit.marast.assignments({ upcoming = true, courseId? }): quizzes from GET /quizzes. With upcoming, it merges only_open and only_future, de-duplicates and sorts by deadline. Otherwise it returns all of them. Output: id, name, courseId, opensAt, closesAt (ISO), status (upcoming/open/closed/unknown, computed against ctx.now()) and extra. Paginated at 100 per page, up to 5 pages.
  • fit.marast.courses(): GET /courses, fetched once since it declares no pagination. Gives the courseIds for filtering.
  • Robust to unknown shapes: list bodies may be a bare array or a wrapped object. Fields are normalised from candidate names (name|title|designation, starts_at|opens_at|…, ends_at|deadline|closes_at|…), and records without an id are skipped.
  • extra is a strict allowlist of descriptive fields (description, max_points, attempts, time_limit, …), with strings truncated to 300 characters. Nothing else from upstream reaches clients.
  • Read endpoints only. courseId is validated as numeric.

Verified

  • bun run lint && bun run typecheck && bun run test: 161 tests, 9 of them for Marast. They cover the merge, de-dup, sort and status, upcoming=false with a course filter, wrapped lists and pagination, the page cap, the allowlist (credential-like keys and a 100,000-character key never pass), courses fetched once, 401 → reauth_required, shape drift, courseId injection, and Czech and English search.
  • bun run dev + curl /api/functions lists 9 functions.

Astra review (gpt-6-astra)

Fixed (all three):

  1. (P1) The denylist on pass-through fields could leak credential-like fields such as authorization or private_key → switched to a strict allowlist.
  2. (P2) Oversized keys bypassed the size bounds → covered by the allowlist. Test asserts the output stays small.
  3. (P2) The unpaginated /courses was requested five times and duplicated → fetched once. Test added.

🤖 Generated with Claude Code

## T12: Marast provider (`fit.marast.assignments`, `fit.marast.courses`) + API exploration **Stacked on #10 (`t11-grades`) → … → #1.** Merge those first. ### Exploring the API (the "+ explore API" part of T12) - The site is a Rails app. `GET /api/v1` itself returns 404, which made the SPEC base URL look wrong. Probing with a bogus Bearer token and `Accept: application/json` showed real routes answering `401 Unauthenticated` (`/courses`, `/quizzes`, `/users/me`, `/notifications`). - **Found a public Swagger doc** at `https://marast.fit.cvut.cz/api/v1/swagger_doc`. Snapshot: `docs/marast-swagger.json`. It lists every endpoint: quizzes, courses, lectures, problems, topics, notifications, roles, users/me, notes and comments. It has no response schemas. - Swagger lists `Api-Key`/`Api-User`/`Api-Pass` header auth. The FIT scope `urn:ctu:oauth:marast:personal` suggests Bearer works too; to verify on the first real login. - The source repo (`gitlab.fit.cvut.cz/marast/marast`) is private, so field names are unknown. Everything is recorded in `docs/apis.md`. ### What - **`fit.marast.assignments({ upcoming = true, courseId? })`:** quizzes from `GET /quizzes`. With `upcoming`, it merges `only_open` and `only_future`, de-duplicates and sorts by deadline. Otherwise it returns all of them. Output: `id`, `name`, `courseId`, `opensAt`, `closesAt` (ISO), `status` (upcoming/open/closed/unknown, computed against `ctx.now()`) and `extra`. Paginated at 100 per page, up to 5 pages. - **`fit.marast.courses()`:** `GET /courses`, fetched once since it declares no pagination. Gives the `courseId`s for filtering. - **Robust to unknown shapes:** list bodies may be a bare array or a wrapped object. Fields are normalised from candidate names (`name|title|designation`, `starts_at|opens_at|…`, `ends_at|deadline|closes_at|…`), and records without an `id` are skipped. - **`extra` is a strict allowlist** of descriptive fields (`description`, `max_points`, `attempts`, `time_limit`, …), with strings truncated to 300 characters. Nothing else from upstream reaches clients. - Read endpoints only. `courseId` is validated as numeric. ### Verified - `bun run lint && bun run typecheck && bun run test`: 161 tests, 9 of them for Marast. They cover the merge, de-dup, sort and status, upcoming=false with a course filter, wrapped lists and pagination, the page cap, the allowlist (credential-like keys and a 100,000-character key never pass), courses fetched once, 401 → `reauth_required`, shape drift, `courseId` injection, and Czech and English search. - `bun run dev` + `curl /api/functions` lists 9 functions. ### Astra review (gpt-6-astra) **Fixed (all three):** 1. *(P1)* The denylist on pass-through fields could leak credential-like fields such as `authorization` or `private_key` → switched to a **strict allowlist**. 2. *(P2)* Oversized keys bypassed the size bounds → covered by the allowlist. Test asserts the output stays small. 3. *(P2)* The unpaginated `/courses` was requested five times and duplicated → fetched once. Test added. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fixtures:record: reads the encrypted token from local D1, GET-only, never refreshes,
  scrubs PII into git-ignored test/fixtures/live/, prints shapes only
- Marast: FIT token goes in Api-Key (Bearer is 401); only_open is 401 → filter locally
- Usermap: fields may be null (preferredEmail) → nullish schemas
- Grades: precise error for missing OAuth app resource; docs on scopes vs. resources

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also fixes a production bug found while smoke-testing: createFnContext stored the global
fetch on the context object, so ctx.fetch(...) threw "Illegal invocation" in workerd. That
broke every registry call in the Worker (including all MCP tool calls); Node tests could
not see it. Regression test emulates workerd's this-check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ALLOWED_USERS enforced for MCP too (shared policy); excluded users' grants revoked on use
- telemetry: streaming 512-byte cap before parsing
- POST /oauth/register rate-limited per IP and instance-wide (Workers rate-limit bindings)
- FIT API bodies capped at 2 MB (streaming); Grades tree walk iterative and bounded
- readBounded moved to src/lib/bounded.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Logos: FIT service marks vendored in public/services (see README there); assistant marks
from @lobehub/icons (MIT), simple-icons (CC0) and devicon (MIT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- service_settings table (migration 0002); MCP calls to a switched-off service fail with
  service_disabled, aggregates skip it with a warning, fit.status lists it
- setServiceEnabledFn: same-origin + session only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fit.sirius.courseEvents: every parallel of a course (room, teachers, capacity/occupied),
  shared paging helper with fit.sirius.events; respects the Sirius MCP switch
- getCourseWeek server fn for the calendar overlay; upcoming deadlines (60 days) beside the week

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- layoutLanes/pragueMinutes in src/lib/calendar.ts (tested)
- shared EventDetails popover; agenda list stays on mobile
- agentSetupPrompt in src/mcp/setup.ts (tested)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Referrer-Policy: same-origin (no-referrer made browsers send Origin: null on our form POSTs,
  so every management form was rejected with 403); shared isSameOriginRequest accepts
  Origin: null only with Sec-Fetch-Site: same-origin
- No COOP on OAuth/consent/login routes (popup-based web MCP clients keep window.opener)
- fit.sirius.icalUrl is websiteOnly: excluded from the MCP registry (URL embeds a credential)
- docs: telemetry wording (operator-enabled, consent advice), CSRF/COOP/credential notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: #25
Reviewed-on: #24
Reviewed-on: #23
Reviewed-on: #22
Reviewed-on: #21
Reviewed-on: #20
Reviewed-on: #19
Reviewed-on: #18
Reviewed-on: #17
Reviewed-on: #16
Reviewed-on: #15
Reviewed-on: #14
Reviewed-on: #13
Reviewed-on: #12
dzenda merged commit 960e11b72e into t11-grades 2026-11-14 17:50:23 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dzenda/os!11
No description provided.