TeamManager could not manage the roster: updateRule allowed self-update
only and deleteRule was superuser-only. The same self-update rule also
left a privilege escalation open — it did not restrict fields, so any
authenticated user could PATCH their own role to "admin".
- team_members rules (migration 1781000003 + base migration + setup
script mirror):
update: (@request.auth.id = id && @request.body.role:isset = false)
|| @request.auth.role = "admin"
delete: @request.auth.role = "admin"
Self-service onboarding keeps working (it never touches role); the
role field is admin-only; deletion is admin-only.
- pb_hooks/team_members.pb.js: the ENTRA_ADMIN_EMAILS resync is now
escalate-only — it guarantees admin for allow-list members on every
login but no longer demotes, otherwise every TeamManager promotion
would revert on the member's next sign-in (ADR-004 amended).
- TeamManager.jsx: info text updated to the new behaviour.
Verified with a two-identity mock-OIDC matrix (11/11): allow-list vs
regular login, self-escalation blocked while onboarding self-update
still works, cross-user update/delete blocked, admin promote/demote/
delete work, promotion survives the member's next login, allow-list
admin stays admin. Regression: #22 matrix 9/9, #24 matrix 8/8, #18
harness 15/15, npm test 112/112, eslint clean.
Closes#27
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every first Microsoft login failed with 403 "Only superusers can perform
this action": PocketBase applies the collection createRule to the
automatic record creation during OAuth2 sign-up, and team_members was
created with createRule null (superuser-only) on the wrong assumption
that the OAuth2 flow bypasses it. Since the pre-Azure records were
deliberately dropped, every user was a first login and nobody could get
in. Reproduced locally against a mock OIDC provider (PocketBase v0.30.4).
createRule becomes '@request.context = "oauth2"': record creation is
allowed exclusively from within the OAuth2 flow. Anonymous REST creates
(which could otherwise pre-seed rogue admin profiles) remain rejected —
covered by an explicit test.
- pb_migrations/1781000002_allow_oauth2_signup.js: applies the rule on
already-migrated environments (Labs); idempotent, guarded, with down
- pb_migrations/1781000000_team_members_to_auth.js: same rule for fresh
environments (filename unchanged — ledger-safe)
- scripts/setup-pb-collections.mjs: fallback entry mirrored
- docs/auth-spec.md: ADR 009 + files table
Verified with a mock-OIDC matrix (9/9): baseline reproduces the 403 on
the old rule; upgrade path heals (first login 200, role/enrollment/name
defaults, second login no duplicate, anonymous create still rejected);
fresh chain + admin allow-list mapping OK. DoD harness regression 15/15,
npm test 112/112.
Closes#22
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the client-side PIN login with real authentication against Azure
Entra ID via PocketBase's built-in OAuth2 (OIDC) flow. Every Azure user is
auto-provisioned as a team_member on first login.
- pb_migrations: team_members becomes a PocketBase auth collection (OIDC
provider configured from env); all collections require @request.auth.id
- pb_hooks: provisioning of role (ENTRA_ADMIN_EMAILS allow-list) and
enrollment_status, with admin-role re-sync on every login
- frontend: "Sign in with Microsoft" login, pb.authStore-based session,
TeamManager manages roster/roles (no PIN/manual create)
- infra: Entra env wiring + pb_hooks mount for dev (Labs) and prod
- src/lib/azureAuth.js + tests for the canonical role/allow-list logic
- docs/auth-spec.md
Knowledge base, generated tests and micro-learnings are left untouched.
Existing PIN users are dropped (no migration required, per sign-off).
Closes#16
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a /topic-test route where learners can take a 5-question test on any
eligible topic at any time. Questions come from a shared, admin-curated
question_bank — no LLM calls on the user path. Points feed the existing
leaderboard with a 10pt/topic/week cap (per ISO week) so the bank can't be
farmed, and repeats from a thin bank yield 0 points.
- New PB collections: question_bank, on_demand_attempts (+ migrations and
setup-pb-collections.mjs entries).
- db.js: un-deprecates getQuizBank/setQuizBank against question_bank so the
existing admin TestManager panel becomes functional again; adds
saveOnDemandAttempt, getOnDemandPointsThisWeek, getUserSeenQuestionIds,
getAllOnDemandAttempts, isoWeekKey.
- testService.js: getEligibleTopicsForOnDemand, startOnDemandTest (unseen-
first draw), finishOnDemandTest (cap enforcement + leaderboard upsert).
- New TopicTest page, route, and nav entry; weekly test flow untouched.
- Leaderboard folds on-demand 100%s into perfect-score count and merges
on-demand attempts into the recent activity feed.
- Spec: docs/on-demand-tests-spec.md with ADRs and extension points.
- Tests: 5 new vitest cases covering eligibility, draw fallback, scoring,
partial cap, and exhausted cap (85/85 total).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>