T9: Usermap provider (fit.me, fit.people.lookup) #8

Merged
dzenda merged 89 commits from t9-usermap into t8-registry 2026-11-14 17:50:38 +01:00
Owner

T9: Usermap provider (fit.me, fit.people.lookup)

Stacked on #7 (t8-registry) → … → #1. Merge those first.

What

  • src/providers/usermap.ts, built against the official Usermap API v1 docs:
    • fit.me: the signed-in user's profile (name, e-mail(s), departments, rooms, phones, personal number), raw role codes, and derived isStudent / isEmployee / isFitMember (FIT is org unit 18000). The username comes from the FIT connection's externalId, which is set at login.
    • fit.people.lookup: exactly one of username (exact) or name (RSQL name=='…', case- and diacritics-insensitive, partial words), with limit ≤ 20. For other people it returns directory data only: no personal number, no roles.
    • A fit.people namespace with Czech keywords (vyučující, kontakt, adresář, …).
  • src/providers/http.ts, a shared FIT API GET helper:
    • Bearer token from the token store.
    • 10 s timeout combined with the caller's abort signal.
    • redirect: "manual": a 3xx is an error, so the token only ever goes to the exact URL.
    • 401 → reauth_required, 403 → a hint about the missing scope, 404 → null, other errors → upstream_error with our own message (the body is never echoed).
    • A shape mismatch → "unexpected response".
  • Injection safety: usernames are validated against ^[a-z0-9][a-z0-9._-]{0,31}$ and URL-encoded. Names are RSQL-quoted with \ and ' escaped, so ;roles=… stays inside the string.
  • Test fixture test/fixtures/usermap/person.doc-example.json is the documented example record. T7 will add PII-scrubbed recordings of real responses.

Verified

  • bun run lint && bun run typecheck && bun run test: 125 tests, 16 of them for Usermap and the HTTP helper. They cover the profile mapping, minimal records, 401/403/404/5xx, unexpected shapes, no redirect following, a timeout during the body read, the RSQL query and escaping, argument validation (including ../admin), the privacy of lookup output, and Czech and English search.
  • bun run dev + curl /api/functions lists fit.status, fit.me and fit.people.lookup.
  • Re-ran the live checks against auth.fit.cvut.cz after the redirect change (still OK).
  • Not yet run against a real token. That waits for the browser login (T7).

Astra review (gpt-6-astra)

Fixed:

  1. (P1) Authenticated fetches followed redirects, and Workers forwards Authorization across origins. Fixed with redirect: "manual" and treating 3xx as an error. Applied in three places: Usermap calls, the T5 FIT token and check_token requests (which carry the client secret), and the T6 login profile lookup. Tests added.
  2. (P2) A timeout during res.json() turned into "unreadable response" instead of temporarily_unavailable. Now the body is read as text first and only JSON parse errors are translated. The same change was made in the FIT client. Test added.

🤖 Generated with Claude Code

## T9: Usermap provider (`fit.me`, `fit.people.lookup`) **Stacked on #7 (`t8-registry`) → … → #1.** Merge those first. ### What - `src/providers/usermap.ts`, built against the official [Usermap API v1 docs](https://kosapi.fit.cvut.cz/usermap/doc/rest-api-v1.html): - **`fit.me`:** the signed-in user's profile (name, e-mail(s), departments, rooms, phones, personal number), raw role codes, and derived `isStudent` / `isEmployee` / `isFitMember` (FIT is org unit 18000). The username comes from the FIT connection's `externalId`, which is set at login. - **`fit.people.lookup`:** exactly one of `username` (exact) or `name` (RSQL `name=='…'`, case- and diacritics-insensitive, partial words), with `limit` ≤ 20. For other people it returns directory data only: **no personal number, no roles**. - A `fit.people` namespace with Czech keywords (`vyučující`, `kontakt`, `adresář`, …). - `src/providers/http.ts`, a shared FIT API GET helper: - Bearer token from the token store. - 10 s timeout combined with the caller's abort signal. - **`redirect: "manual"`**: a 3xx is an error, so the token only ever goes to the exact URL. - 401 → `reauth_required`, 403 → a hint about the missing scope, 404 → `null`, other errors → `upstream_error` with **our own message (the body is never echoed)**. - A shape mismatch → "unexpected response". - **Injection safety:** usernames are validated against `^[a-z0-9][a-z0-9._-]{0,31}$` and URL-encoded. Names are RSQL-quoted with `\` and `'` escaped, so `;roles=…` stays inside the string. - Test fixture `test/fixtures/usermap/person.doc-example.json` is the documented example record. T7 will add PII-scrubbed recordings of real responses. ### Verified - `bun run lint && bun run typecheck && bun run test`: 125 tests, 16 of them for Usermap and the HTTP helper. They cover the profile mapping, minimal records, 401/403/404/5xx, unexpected shapes, no redirect following, a timeout during the body read, the RSQL query and escaping, argument validation (including `../admin`), the privacy of lookup output, and Czech and English search. - `bun run dev` + `curl /api/functions` lists `fit.status`, `fit.me` and `fit.people.lookup`. - Re-ran the live checks against `auth.fit.cvut.cz` after the redirect change (still OK). - Not yet run against a real token. That waits for the browser login (T7). ### Astra review (gpt-6-astra) **Fixed:** 1. *(P1)* Authenticated fetches followed redirects, and Workers forwards `Authorization` across origins. Fixed with `redirect: "manual"` and treating 3xx as an error. **Applied in three places:** Usermap calls, the T5 FIT token and `check_token` requests (which carry the client secret), and the T6 login profile lookup. Tests added. 2. *(P2)* A timeout during `res.json()` turned into "unreadable response" instead of `temporarily_unavailable`. Now the body is read as text first and only JSON parse errors are translated. The same change was made in the FIT client. Test added. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applies to Usermap calls, the FIT token/check_token requests and the login profile lookup.

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>
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
Reviewed-on: #11
Reviewed-on: #10
Reviewed-on: #9
dzenda merged commit 9430da949b into t8-registry 2026-11-14 17:50:38 +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!8
No description provided.