First Microsoft logins failed with 400 "email: Cannot be blank": Graph's
/oidc/userinfo endpoint omits the email claim for accounts without a
mail attribute, and team_members requires an e-mail (it also drives the
ENTRA_ADMIN_EMAILS role mapping). Reproduced against a mock Entra with
PocketBase v0.30.4; the user-supplied response body matched the
missing-email fingerprint exactly.
- provider config (reconciler + migration fast-path): userInfoURL is now
empty, so PocketBase reads the claims from the Entra id_token, which
always carries preferred_username (UPN) and email when available. The
#18 reconciler flips the already-deployed Labs provider automatically
on the next boot — no migration needed.
- pb_hooks/entra_oidc.pb.js: onRecordAuthWithOAuth2Request hook falls
back to the lowercased UPN when the email claim is absent. Guest UPNs
(ext_user#EXT#@tenant...) are excluded explicitly — "#" is RFC-valid
in an e-mail local part, so both a naive regex AND PocketBase's own
validation would accept them (caught by test V5). The hook also logs
every failed OAuth2 attempt with the underlying error to the container
log, which PocketBase otherwise only writes to its internal logs db.
Verified with a switchable mock-Entra matrix (8/8): no-email→UPN e-mail
+ allow-list role, email claim wins when present, no duplicate on
re-login, guest UPN yields a clean validation error, anonymous REST
create stays rejected, both log lines present. Regression: issue-22
matrix 9/9 (baseline pinned to 1a1351d now that #23 is merged), DoD
harness 15/15, npm test 112/112.
Closes#24
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>
PocketBase on Labs crash-looped since the SSO deploy (PR #17): mounting
--migrationsDir for the first time replayed the entire migration history
against a database that was provisioned out-of-band (empty _migrations
ledger) and died on 1778948471_created_content.js. On top of that the
team_members->auth migration had its own crash paths and trapped OAuth2
config inside a one-shot, env-dependent migration.
- pb_migrations/1000000000_baseline_ledger_sync.js: detects an
out-of-band provisioned DB (schema exists, ledger empty) and marks the
79 historical migrations as applied; no-op on fresh or already-synced DBs
- pb_migrations/1781000000_team_members_to_auth.js: idempotency guard,
relation->text conversion WITH data preservation (PocketBase diffs
fields by id, so the column is backed up and restored via SQL), unique
index rebuild, no silent catches, env only as fast-path
- pb_hooks/entra_oidc.pb.js + pb_hooks/utils.js: reconcile the Entra OIDC
provider from ENTRA_* env on every bootstrap + cron tick
(compare-before-save, warn-once); heals environments that migrated
without secrets and supports secret rotation without re-apply
- pb_hooks/team_members.pb.js: require() pattern — JSVM runs callbacks as
isolated programs, top-level helpers are not in scope (adopts Leroy's
fix from the fix-sso branch)
- infra/*/site/deploy-playbook.yml: health-gate after compose up — the
deploy fails loudly with container logs when PocketBase does not become
healthy (runs #83-#88 were green while PB crash-looped)
- docker-compose.yml: .env.local is optional again
- docs/auth-spec.md + AI_AGENT.md: ledger/reconciler documentation, ADRs
006-008, never-rename-applied-migrations warning
Verified locally against PocketBase v0.30.4 with a 7-scenario DoD matrix
(fresh DB +/- env, out-of-band DB with data incl. data preservation,
populated-ledger upgrade, late-secrets healing, re-run guard, hook
provisioning): 15/15 pass. npm test 112/112.
Closes#18
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>
- New theme_sessions + theme_session_completions PocketBase collections
- Generate a detailed per-week theme summary covering every topic in the
week's curriculum slot via EMIT_THEME_SESSION_TOOL; cache per
(curriculum_version, week_number)
- Replace Leren.jsx "This Week's Topic" card with "This Week's Theme
Session" that opens the new ThemeSessionView; per-topic micro-learnings
remain reachable as optional practice from inside the session
- Group the Knowledge Library by topic.theme, pin the current week's
theme on top, and badge topics already covered by the active session
- Mark week complete when the user finishes reading the theme session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add dependency-free TF-IDF retrieval (src/lib/retrieval.js) with NL+EN
stopwords and a WeakMap-cached index.
- Rewrite buildKbContext to ship the top-K relevant topics + verbatim-
mentioned ids only, filter relations to the included set, and append a
[kb_hash: <8 hex>] suffix so the ephemeral prompt cache busts when the
graph changes. Returns { context, retrievedTopics, allTopics }.
- Add LOOKUP_TOPIC_TOOL and drive useChat through callLLM directly with a
multi-hop tool_result loop capped at 3 hops; preserve Anthropic-provided
tool_use ids through callLLM so the loop can echo correct tool_use_id.
- Truncate R42 history to the last 12 turns and prepend a single
"(earlier conversation truncated)" assistant message.
- Set R42 chat defaults: temperature 0.3, maxTokens 2048.
- Add pb_migrations/1780500002_created_llm_calls.js (the best-effort
logger in callLLM was already wired) and a new Admin → Diagnostics
view showing the last 100 calls with token usage, cache-hit rate, and
USD cost from a local Anthropic price table.
- Finalize AI_PIPELINE_HARDENING_PLAN.md: mark Phases 1–5 shipped and
Phase 6 (eval harness) explicitly out of scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace stateless one-shot extraction with a stateful, paced, cancellable
pipeline. Six subtasks:
- 3.1 Sentence-aware chunking with 800-char overlap (was paragraph-only
at 4000 chars). Hard-split fallback for runaway sentences.
- 3.2 Stateful extraction: chunks 2+ receive an "already-extracted topic
IDs" hint capped at 200 IDs, so the model reuses IDs instead of
inventing variants like software-developer vs software-engineer.
- 3.3 Token-bucket limiter in llmRetry.js (extractionLimiter, 5 req/min).
callLLM awaits the limiter before fetch; 429+Retry-After calls
pauseUntil. Replaces hard setTimeout(12000) and setTimeout(15000).
- 3.4 relevance_locked column on topics — admin edits to relevance are
sticky across re-extraction. Migration + merge respects the flag +
unlock checkbox in KnowledgeGraph edit form.
- 3.5 Unify relation vocabulary — handbook prompt no longer mentions
legacy "executes"; one-shot migration rewrites existing executes rows
to executed_by with source/target swapped.
- 3.6 Cancellation — Cancel button on UploadZone wired to an
AbortController threaded into callLLM; aborted runs persist status =
"cancelled" rather than "failed".
Tests: 16 new unit tests for chunkText, buildKnownIdsHint, and
createLimiter. All 61 tests pass, 0 lint errors, build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>