What's new in NOBA.
postcss 8.5.8 → 8.5.14 (GHSA-qx2v-qp2m-jg93, moderate). Closes a parse-time XSS shape in PostCSS's CSS parser. PostCSS is a build-time dependency of the frontend bundle (Vite / dompurify chains); the upstream advisory is moderate severity and the bump is a clean lockfile-only change. Confirmed by npm audit --omit=dev back to 0 vulnerabilitiesnoba-agent-core reconnect path no longer wedges when the primary WS endpoint becomes unreachable mid-session. Two layered fixes. (1) AgentConfig.fallback_urls — operators can configure additional reachable WS endpoints (Tailscale IP, LAN IP, public DNS) and the reconnect loop cycles through them on every attempt; a Tailscale subnet flap or DNS-resolver hiccup no longer pins the agent to a single broken URL for the full backoff window. (2) WS reconnect URL cycling is now decoupled from the exponential backoff timer with added jitter and a time-based reset — previously the URL only advanced on a successful BIND, so a transient failure on URL #1 burned through the whole backoff curve before URL #2 was even tried. The reset window means a long-stable agent that drops on idle reconnects fast instead of starting at the post-failure cap. Field-validated on Tailscale MagicDNS-hiccup-affected runsrust. Handshake protobuf gains an agent_type field that the Rust agent populates with "rust" (Python agent keeps "python"; absent fields default to "rust" on the wire). Closes the second half of the b.35 auto-update gate (b.35 closed the metrics-report side via MetricsReport.agent_type field 21; this closes the BIND-time side). Now both the long-poll telemetry channel and the initial handshake carry a positive identification, so the auto-update path can never re-engage the Python push loop against a freshly-spawned Rust agent that hasn't yet emitted its first metrics reportdjoin quoting fix. odj_provision and odj_join command paths switch from run_shell to run_argv on the Windows agent. run_shell dispatches via cmd.exe /C "<command>", and cmd /C's quote-removal rules mangle the embedded paths and machine-name arguments that djoin ships with (the djoin /provision blob path and the /loadfile blob path both can legitimately contain spaces). run_argv executes the binary directly through CreateProcessW with each argument as a discrete LPCWSTR — Windows handles the quoting consistently and the call no longer fails on a path with a space or an apostrophe. Surfaced during the M3 cross-domain ODJ verification passmain: a hand-rolled Rust DC-replication agent (noba-agent-dc) that talks DRSUAPI to a domain controller without any Samba or Impacket runtime, and the orchestration spine for M2 / M3 machine migration — Saga engine, REST surface, fleet cutover, GPO / DNS / SQL / IIS / HAADJ verification waves, plus the frontend Machine Migration Panel wired through. The release also restores PostgreSQL portability of the audit chain, ships a cross-vendor live load matrix (samba ↔ msad and msad ↔ msad), and lands the rollback symmetry for sIDHistory write-back the AD migration was missingBIND with Kerberos auth (libgssapi), DRSBind three-leg round trip, DRSGetNCChanges V8 request + V6 reply parser with full DCERPC fragment reassembly, REPLENTINFLIST chain walk with the 7 password ATTID decrypt hook, EXOP_REPL_OBJ secrets request and DES per-user unwrap, linked-attribute and uptodateness-vector decoders, bulk per-object secret extraction with the supplementalCredentials AES Kerberos walker, an offline NTDS.dit reader (ESE + regf hive + PEK list AES unwrap), a sIDHistory FFI scaffold via DsAddSidHistoryW, and KDS root-key migration over plain LDAP. Live-verified against samba-ad.noba-test.local — 27 user objects plus krbtgt extracted in a single 21-cycle full-replica run with all four credential ETYPES (NT, AES-256, AES-128, DES-CBC) bit-exact against samba-tool user getpassword and the Server 2025 offline path bit-exact against impacket's online dump. The crate is feature-gated (--features dc) and the CI matrix builds it under libclang + libkrb5 for the Linux agent and falls back to a clear-error stub on Windows / no-system-deps profiles/api/enterprise/ad/projects/{id}/machine-migration cover plan / execute / verify / cutover / rollback for the machine wave. Six new agent command families on the Windows-side agent: machine_prestage / machine_unjoin, fs_utils, dpapi, PKI, dMSA, ODJ blob provisioning, SDDL, telemetry; cloud-side commands cover cloud-leave, profile redirect, WAM clear, and WHfB purge. Verification waves cover DNS record pruning, GPO harmonization at the domain delta, API rate-limit throttling against the join controller, HAADJ device-registration round trip, SQL Server login remap on remapped service accounts, IIS application-pool identity remap with an HTTP-GET PASS probe, and a fleet cutover gate inside ad_project_runner.execute_project that holds the wave until the per-machine readiness ledger is green. Machine prestage waves and source-SPN strip both run inside the project transaction so a half-finished wave is observable, not silentaxe-core) added alongside — every panel renders with no severity-critical a11y violations on every push, not just at release time_dispatch_remove_sid_history + a _reverse_one branch + a terminal status-flip; ldap_writer.remove_sid_history_value issues the MODIFY_DELETE leg; WriteAdapter.remove_sid_history dispatches LDAP-only; db.ad_sid_history_log.mark_sid_history_rolled_back flips the latest attempt status; and wave-7 now writes through ad_write_log with a rollback_json column so a sIDHistory write that succeeds at the wire but fails to log is no longer a silent loss. End-to-end forward + rollback round trip locked in on an isolated DB. With these in, the AD migration deception layer closes — eight prior silent-corruption gates (§14 #14–22) wired to hard-fail in the same releasedb/audit.py::audit_log() replaces the SQLite-only SELECT last_insert_rowid() with portable cur.lastrowid. On PG the prior code raised function last_insert_rowid() does not exist, the outer try / except swallowed it, and every audit entry on PG landed with the row inserted but the audit-chain integrity stamp (prev_hash / entry_hash) absent. NIS2 audit-chain integrity is a documented platform property — this regressed it for any Enterprise install on PG; the fix pairs with audit table registration in db/postgres_adapter._TABLES_WITH_AUTO_ID so PG auto-appends RETURNING id and populates cur.lastrowid. Five separate column-introspection probes (ad_defederation ×3, ad_mfa_coverage, ad_mfa_enrollment_log) rewritten from SELECT * FROM <t> WHERE 0 to WHERE 1=0 so PG's strict-type WHERE clause stops raising on every defederation rollback-window poll and MFA-coverage list. Locked in by tests/db/test_pg_cross_backend_portability.pytests/live/test_ad_load_user_migration.py extends the same in-process FastAPI TestClient + sidecar-GET-hammer + /execute polling rig from the PR #70 same-vendor baseline to the cross-vendor pair samba204 → msad_sitea (forward) and msad_sitea → samba204 (reverse), then closes the 2 × 2 vendor matrix with the msad ↔ msad pair (msad_sitea ↔ msad_test, both MS Server 2025 FL = 2025). The stamping helpers gain a uac parameter — cross-vendor msad-source uses 0x202 + include_disabled=True because MS-AD rejects "user without password" on a SAM-create. Exercises the engine's accepted_upn_suffixes runtime guard for the case where source users carry @msad-sitea.local UPNs but the target forest only accepts its own suffix. Gated behind NOBA_LOAD_TEST_CROSS_DOMAIN=1 so a CI lane can pick the pair up independentlynoba-agent.pyz to a Rust agent. Without the gate, a Rust agent reporting a version different from the server-known Python build would loop reconnect → push → restart → reconnect every ~1.5 seconds — observed on CT 210 in the lab. New protobuf field MetricsReport.agent_type (field 21) plus the dispatcher early-returns when agent_type != "python". Pairs with the hand-curated Python proto stub gotcha — newly-added proto fields silently get stripped by _proto_to_legacy_dict until they're enumerated explicitly there. Closes the regression that originally surfaced this gap; beta.38 closes the BIND-time half via the Handshake protodsar.py, ad_kds export error path, and the M3 orchestration error path all stop leaking raw exception detail into HTTP responses. The agent-build cross-compile fixes the broken examples target plus the missing --features dc on the workspace cross-compile, and the ct-deploy script gets a force-reset of the working tree before the SHA checkout plus a retry on MagicDNS timeouts. Lab IPs redacted from the in-tree handoff docs to keep the CI IP-scan gate greennoba-agent-dc crate ships with 82 unit tests covering the four ETYPE decrypt paths, the V6 / V8 wire codecs, the ESE / regf / PEK chain, and the sIDHistory FFI scaffoldmain, with a real cross-vendor live test matrix replacing the prior single-direction ad-hoc smoke. Bug 10 (Graph 404-on-delete idempotency) deferred while we investigate whether the test's assumption matches the lab tenant's actual DELETE behavior — three v2 fix shapes converged on the same red and the audit trail confirms the lab observes 404+404 within the same second on a freshly-created group; PR left open as a record. Backend passes 5229 tests on workstation and 5141 on CT 210 (the one CT 210 delta is a docker-absent lab-infra check that's unrelated)POST /api/enterprise/ad/projects/{id}/reconciliation/bulk-resolve endpoint accepts either an explicit finding_ids list or a filter dict (category / severity / resolution) plus a resolution string and applies the same outcome to every matching row inside one SQLite transaction. Per-row PATCH still exists for surgical edits. Hard-cap 5000 ids per request, chunked at 500 to stay below SQLite's 999-parameter limit. Frontend ships per-row + select-all checkboxes plus a bulk-resolve toolbar with Apply to selected (N) and Apply to all matching {filter}. Backed by new DB helpers bulk_resolve_reconciliation + get_reconciliation_by_ids. Live-driven against the AD lab — a 50-row resolve completes in under 30s wall, vs. the ~25s/PATCH the per-row path used to take under Bug 11/12 contention (a 1000-user project's 8.3-hour click marathon collapses to a single click)idx_audit_tenant_id_desc on (tenant_id, audit_chain_id DESC) in the audit table drops the per-PATCH chain-walk from a full-table scan to an index hit; reconciliation finding PATCH wall time falls below 5s on the live AD lab, and concurrent-GET pressure on the same project no longer evicts the SQLite write lock long enough to surface an HTTP 503 to the operator. Verified by the same scope8-live-t1 lane that surfaced the regression — green on every push sincesAMAccountName collisions where the same SAM exists on a different OU/object class in the target forest (not just inside the planned destination OU); and (2) sanitize-truncated mismatches where the source SAM is over the 20-char MSAD ceiling and the canonicalized target SAM happens to collide with a pre-existing distinct user. Both surface as severity=critical findings with a collision_kind tag so the operator can resolve via the bulk path above. Live-validated against msad_test → samba205 with the messy-migration exploratory datasetcompleted → rolled_back transition, so the run-status badge updates without an operator-initiated reload. Two layered fixes: the polling loop fires when inTransitional || isRollingBack (not just on active), and a defense watch on selectedProject.id re-arms the loop when the operator switches projects mid-rollback. 8/8 Vitest cases pass; live-verified end-to-end on the AD lab through the rollback-completed transitionrolled_back badge persists across page reloads. Project run-status surface now classifies rolled_back as a distinct terminal state with its own badge styling instead of degrading to completed after the page is refreshed. Backend status payload + frontend badge component both updated; the run-history table colour-codes rolled_back rows and the active-run header preserves the badge through the next page renderscope8-live-t1 now redeploys CT 210 (noba-web-test) to github.sha via ssh → pve → pct exec 210 running ct210-deploy.sh before the AD live tests run, closing the version-skew gap where a runner-side test could disagree with the (older) deployed engine and produce noise that wasn't a real regression. The deploy is pinned to github.sha, not the branch tip, so a follow-up push landing mid-run can't silently shift CT 210 onto an untested commit. (2) Session-start tenant cleanup fixture (tests/live/_tenant_cleanup.py) scrubs leftover stamped objects (health-, mx-, m2a-, live-, src-, tgt-) across both Graph tenants and all five LDAP DCs before any live test runs — per-test finally cleanup doesn't fire on SIGKILL or fixture-init failure, and the resulting clutter degraded Microsoft Graph's eventually-consistent advanced-query index, amplifying replica-drift flakes on tests that create-then-immediately-read. Per-tenant _MAX_DELETE_PER_KIND=100 ceiling refuses to mass-delete past that bound — too-broad filter surfaces for operator investigation, never silent destruction. The scope8-live-t1 pytest glob also expanded beyond test_ad_*.py to collect the migration matrix (test_msad_*.py, test_samba_*.py, test_azure_*.py) plus the discovery suite (test_pso_*.py, test_forest_trust_*.py, test_group_scope_*.py, test_gpo_*.py) — nine files that landed earlier but were silently uncollected because the glob was too narrowad_preflight_engine.check_forest_level + check_schema_compatibility now default to soft-mode: source-to-target FFL or schema downgrade returns passed=True with a severity="warning" / severity="info" advisory, matching the severity model the reconciliation engine has used since Phase 1. The historical target >= source hard-fail is preserved under an opt-in strict=True kwarg, exposed per-project via config.preflight.strict_delta_mode with a NOBA_PREFLIGHT_STRICT_DELTA=1 env-var escape hatch. Preflight is informational anyway — ad_project_runner.execute_project already probes target schema per-attribute and skips unsupported attrs — so soft mode lines the preflight panel up with the product behaviour that has been live-green across msad → samba, samba → msad, msad → azure, and the messy full-attribute-surface matrix. Microsoft's own guidance is explicit that FFL governs intra-forest DC behaviour only and does not affect clients or applications. New PATCH /api/enterprise/ad/projects/{id}/config endpoint does a shallow-merge on an allow-list (preflight, coexistence_deadline_days, auto_register_mfa) so partial updates don't clobber siblings; nested dicts deep-merge one level. Frontend gets a strict-delta toggle on the preflight step with collapsible help copy and a severity-aware advisory listconnectors/graph_writer.delete_user() now logs at WARNING level on every DELETE request and response (URL, user_id, HTTP status, error message). Quiet by default before this release, which made rollback investigations harder than they needed to be when Graph returned 404 on a freshly-created object — the kind of trace that drove the Bug 10 deferral aboveusers_per_hour that conflates forward and rollback runs — the math is correct but the operator can't see whether 28 processed = 14+14 or 28+0. Tracked for a follow-up release with a per-run-type sub-tally; not a regression. Auto-updater downgrade-detection guard queued for the same window after a CT 210 dev-environment incident where a release-cut commit's VERSION = beta.36 bump preceded the published tarball, triggering an auto-downgrade to beta.35. Production users only ever upgrade forward against published versions so this is not believed to affect production installs, but a code-trace pass is queued before the next release to confirm. Companion UserStore.load() integrity check (reject malformed users.conf rows at load time instead of absorbing them into hash[0] and propagating via save()) is also queued; properly-configured installs (admin email + verified + enterprise license + SMTP) have UI recovery via POST /api/auth/forgot-passwordmain: Feature A Automated Defederation, Feature B Post-Migration MFA Enrollment + Temporary Access Pass, and Feature C MFA Coverage Audit Report for NIS2 Art 21(2)(j). All three live-validated against the Nobacmd P2 trial tenant (6/6 live probes on Feature A READ paths, 8/8 end-to-end on Feature B, 5 probes on Feature C); 5142 backend tests pass, 158/158 VitestFederated to Managed via Graph PATCH /domains/{id}. Seven cloud-probed preflight checks (/organization sync state, isRoot / isVerified, federation config backup of 12 fields, signing-cert fingerprint for drift detection), eight-item operator attestation chain with case-insensitive typed domain confirmation, 60-minute propagation window tracked by a background worker with a 90-minute hard cap, and a 24-hour bounded rollback window that restores all 12 captured federation fields on one click. Advisory lock per (tenant_id, domain_id) prevents concurrent flips; pre-PATCH cert-fingerprint drift check guards against stale backups. Sovereign-cloud-aware consent URLs cover global / gcc_high / dod / china. Requires 3 new admin-consented Graph perms: Domain-InternalFederation.ReadWrite.All (primary), Domain.ReadWrite.All (fallback), User.RevokeSessions.All (optional). Destructive flip itself is not live-tested against the trial (P2 tenant is Managed-only — documented residual risk); READ-path and dry-run preflight are live-validatedFEATURE_FLAG_AD_MFA_ENROLLMENT=1). Auto phone + email pre-registration runs as Wave 5 of migration (operator opts-in per project) so migrated users aren't stranded without a strong factor on first cloud-domain login. Operator-triggered Temporary Access Pass issuance with show-once plaintext delivery — the plaintext TAP is never persisted, never logged, never returned on anything but the single one-shot response, enforced by four defense layers (schema has no plaintext column, graph_writer response redactor, engine never passes plaintext to DB, router never returns it twice). Tenant TAP policy preflight clamps the requested lifetime to the tenant policy and passes DisabledByPolicy through as an actionable warning. Full rollback via ad_rollback_driver — 3 new ops (delete_phone, delete_email, delete_tap) with a refusal branch when the user made the registered phone their default MFA. New Graph permission: UserAuthenticationMethod.ReadWrite.All (separate customer tenant re-consent). New DB table ad_mfa_enrollment_log (22 columns, 6 indexes; per-method per-user audit)phishing_resistant / passwordless / mfa / none) per migrated Entra directory. Server-side collector runs on a 24h cadence piggybacked on the sync worker; graph_throttle.priority_scope("Low") keeps a running migration from starving on coverage ticks. License preflight via /subscribedSkus service-plan GUID match (P1 41781fb2-…, P2 eec0eb4f-…) with /organization?$select=assignedPlans as the 403-fallback path; free-tier tenants short-circuit on the Authentication_RequestFromNonPremiumTenantOrB2CTenant 400 response. Classifier is conservative on unknown method strings — never up-classifies to phishing_resistant, always under-reports rather than over-reports. Four export surfaces: streamed per-user CSV + separately signed manifest, aggregation-only CSV, reportlab-rendered PDF (pure Python, no cairo/pango runtime deps), and per-user GDPR erasure. Service principal must also be assigned the built-in Entra Reports Reader role — without it, userRegistrationDetails returns 403 even with AuditLog.Read.All consented. Admin-gated, P1/P2 tenants only. NOBA_MFA_COVERAGE_RETENTION_DAYS env var (default 365) for compliance-evidence retentiongraph_throttle.priority_scope("High"|"Low") context manager attaches the x-ms-throttle-priority request header to every Graph call made inside the scope — no kwarg-threading through 40 signatures. Migration and rollback paths enter High; the background sync worker enters Low; invalid levels silently fall back to Normal. Microsoft throttles Low first and High last, so this is a fairness signal between NOBA's own workloads under cross-tenant throttle pressure, not a limit change. classify_response now extracts x-ms-throttle-information (e.g. CPULimitExceeded, WriteLimitExceeded) for 429 forensics, and parses the IETF-standard RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers opportunistically so the pipeline lights up automatically when Graph rolls them out beyond the current SharePoint-preview scopeserver/adr008_entra_watch.py polls the raw MicrosoftDocs/entra-docs markdown every 24h alongside the existing Graph changelog RSS watcher — added after 2026-04-17 investigation of the June 1, 2026 Entra Connect hard-match restriction revealed that change was announced only in the Entra feed, not the Graph changelog. Without the second watcher, an entire class of Entra identity-layer deprecation would not have tripped ADR-008 review trigger #4. Confirmed via grep that NOBA's AD-to-AD path does NOT use onPremisesImmutableId / Entra Connect / Cloud Sync — so the restriction itself does not apply to NOBA migrations, only to customers running Entra Connect in parallel hybrid scenarios (their infra, documented for runbook visibility)AdOperationsTab.vue with internal subnav tabs. The wrapper shipped but was unreachable from the UI and the inner-tabs UX was rejected on verification. Refactored: wrapper deleted, AdDefederationPanel now routes directly at /settings/ad-defederation (admin-only), AdMfaEnrollmentPanel at /settings/ad-mfa-enrollment (operator-or-admin), both surfaced as top-level sidebar entries under PROGRAMS → AD OperationstoLocaleString; active-run header pulls the full run_id after the store normalizes it across the id / run_id API drift; flipped-state propagation countdown ticks every second ("Propagation in progress — HH:MM:SS remaining"); defederation + sync log tables match each other with uppercase headers and border-collapse; Connect button no longer wraps mid-word; Health Score gauge empty state breathes with an accent-tinted pulse instead of reading as a dead disc; Live-pill heartbeat blink restored in the app header; API Docs sidebar link hidden unless apiDocsEnabled is on in YAML (was dead-linking to 404 by default)otel_ad_defederation.py had been hard-importing from opentelemetry import metrics since the first Feature A commit, crashing 5012 pytest tests plus 7 test-live collection errors in CI environments where opentelemetry-api isn't installed (NOBA's CI pip install lines are curated allow-lists, not requirements.txt). Applied the same try/except Noop fallback pattern otel_graph.py already uses; counter/histogram calls degrade to no-ops. All 14 gitea Actions jobs green on the merge commitdompurify bumped ^3.3.3 → ^3.4.0 to close GHSA-39q2-94rc-95cp (moderate; ADD_TAGS / FORBID_TAGS bypass). NOBA's only use at AiChatPanel.vue:56 is the ALLOWED_TAGS whitelist path and was not vulnerable, but the bump brings npm audit --omit=dev back to 0 vulnerabilitiesrelease-gitea.yml now authenticates to Docker Hub before the docker build base-image pull (gated on two gitea secrets: DOCKERHUB_USERNAME + DOCKERHUB_TOKEN, using a dedicated read-public-repos service account). The unauthenticated anonymous pull had started hitting Docker Hub's 100/6h rate limit mid-release, 429-failing the build on valid tags. Authentication alone lifts the ceiling to 5000/24h/account regardless of PAT scopeghcr.io to a SHA-verified tarball served directly from R2 under nobacmd.com. Previous operators who followed docker pull ghcr.io/raizenica/noba-enterprise:latest were silently pulling an older version or hitting a 401 ever since the upstream code repo went private in early April — the new flow fixes that with zero registry dependencycurl -fL https://www.nobacmd.com/download/latest/docker-install.sh | bash resolves the current version via /download/latest/version.json, downloads noba-enterprise-<V>.docker.tar.gz and its .sha256, verifies the hash, gunzips, docker loads, and tags the image as noba-enterprise:latest so docker run / docker compose up work immediately. Pin a specific version with NOBA_VERSION=… — the script reads the env var before hitting the version.json. Checks prerequisites (docker daemon reachable, curl, gunzip, sha256sum) and fails fast with operator-friendly messages rather than half-loading an imagerelease-gitea.yml runs docker build + docker save | gzip at every tag push, uploads the .docker.tar.gz + SHA to the Gitea release and to s3://noba-releases/v<V>/. docker-install.sh is uploaded to both the per-version path and the stable /latest/ path so the website one-liner resolves without any intermediate redirect. version.json gains a "docker" field so the install script picks up the tarball path without extra round-tripsdocker-compose.yml now uses image: noba-enterprise:latest (the local tag the install script writes). Header comment documents the 3-step operator flow — curl-bash, compose up, read the first-run password from docker logs. build-from-source escape hatch preserved one line below/download/latest/docker-install.sh endpointUser-Agent: noba-command-center/2.1 on /v1/watchers/login, which some LAPI deployments reject with HTTP 401 because the stored machine User-Agent (set at cscli machines add time) doesn't match. The watcher JWT login and every authenticated call now use crowdsec-lapi-client-noba/2.1, matching CrowdSec's upstream bouncer / LAPI-client naming convention so LAPI recognizes NOBA as a legitimate watcher. Validated end-to-end against Docker crowdsecurity/crowdsec:v1.7.7 — the A/B UA probe returns 401 on the old UA and 200 + token on the new onenoba-web.service now carries StartLimitBurst=5 and StartLimitIntervalSec=60 in the [Unit] section. Prevents the scenario where an orphaned LISTEN socket survives a crash and wedges port 8080 across every subsequent restart — after five failed starts in sixty seconds systemd stops retrying and the failure becomes visible instead of silently looping. Complements the SO_REUSEADDR patch that shipped 2026-04-02. Validated via a user-scope systemd-run crash-loop harness — journal reports "Start request repeated too quickly" at exactly restart counter is at 5, matching the configured burst/api/agent/install-script?type=rust&platform=windows, no more references to building from source or the deprecated Python-agent install script. The release workflow cross-compiles noba-agent-<version>-windows-x86_64.exe plus a SHA-256 checksum at tag time and publishes both alongside the tarball and DEB — the Windows binary used to live only in a 30-day CI artifact, now it rides the tag. Validated by this tag push: the .exe is live on the Gitea release page and on the R2 download bucket at /download/v2.1.0-beta.33/server/adr008_cve_watch.py background thread polls OSV every 24h for new advisories on eight packages whose CVE feeds would indicate NOBA's hand-rolled OAuth2 client-credentials pattern has the same bug shape: azure-identity, msal, msgraph-sdk (the Microsoft SDKs intentionally not adopted), authlib, oauthlib, requests-oauthlib (functionally equivalent Python OAuth2 clients), and PyJWT + httpx (direct deps). Advisories with CVSS ≥ 7.0 fire a WARNING-level log line plus an audit-trail event under category adr008_cve_watch, surfacing the ADR-008 review requirement the moment it becomes relevant. Seen CVE IDs persist to ~/.local/share/noba-adr008-cve-seen.json to prevent re-alerting on every pollserver/adr008_graph_watch.py background thread polls the Microsoft Graph changelog RSS feed every 24h for deprecation, deprecated, v2.0, version 2.0, breaking change, retirement, retired, and sunset keywords. Matches fire a WARNING log + audit-trail event under category adr008_graph_watch so NOBA sees the 24-month v2.0 migration window the moment Microsoft announces it — well before the SDK-adoption vs. raw-HTTP cost trade-off becomes time-critical. Closes the "scheduled task on the Graph changelog" open question raised when ADR-008 was filedpytest-xdist -n auto. Lifespan smoke on fresh HOME clean — both watchers start with the app lifespan hook and stop cleanly during shutdowndocs/adr/ADR-008-hand-rolled-graph-client.md updated with pointers to the new modules, so a future auditor reading the ADR knows the triggers are enforced in code, not just documentedauth_social.py audit after beta.29 (P0) and beta.30 (P1) shipped. The full audit scope is now implemented; NOBA's Entra SSO matches the OpenID Connect 1.0 spec end-to-end for authorize, callback, token validation, claims-challenge, logout, and single-logoutGET /api/auth/social/microsoft/logout?token=<noba-token> revokes the current NOBA session immediately and 302s the browser to Entra's end_session_endpoint with a post_logout_redirect_uri back to the NOBA login page. Works regardless of whether the IdP actually supports RP-initiated logout — if end_session_endpoint is absent from the discovery doc, the handler clears the local session anyway and returns the user to the login pagePOST /api/auth/social/microsoft/backchannel-logout receives form-urlencoded logout_token JWTs directly from Entra per the spec. Validation enforces every required check — signature against the tenant JWKS, iss, aud, iat, required jti freshness, required events claim containing http://schemas.openid.net/event/backchannel-logout, absent nonce (spec §2.6 — logout tokens MUST NOT carry a nonce), at least one of sub or sid. On success, NOBA sessions matching the email claim are revoked via the existing revoke_user_sessions helper. Sub-to-session mapping (so the IdP can terminate sessions it knows about but we don't have an email for) is scheduled for a later releasejti replay cache. auth_oidc_verify.verify_id_token now rejects any id_token whose jti claim has been seen before. Cache is a bounded in-memory OrderedDict (10k entries) with time-based eviction on every lookup. Catches the case where an id_token is captured in transit and replayed before it naturally expires. The same cache backs the freshness check required by verify_logout_token so a single logout event cannot be replayed to terminate additional sessions/me GET in auth_social.py were previously synchronous httpx.post/get calls inside async def handlers — each one blocked the event loop for the duration of the Entra round-trip (200–1500ms under normal load, longer on slow networks). beta.31 converts them to httpx.AsyncClient and awaits them properly. No functional change; removes head-of-line blocking during login under concurrent loadauth_oidc_verify.verify_logout_token exposes the back-channel-logout validation as a public surface so future integrations (Keycloak, Authentik, Google workspace) can adopt it without re-implementing the spec. 8 new tests; 4810 total pass under pytest-xdist -n auto. Lifespan smoke on fresh HOME cleanauth_social.py audit after beta.29 closed the five P0 NIS2 gaps. beta.30 closes the P1 tier — strict at_hash, Conditional Access insufficient_claims surface-through on the login path, and generic-OIDC discovery hardening per RFC 8414 §3.3. P3 items (logout semantics, jti replay cache, async-httpx conversion) remain scheduled for beta.31at_hash when an access_token is issued. auth_oidc_verify.verify_id_token now takes a require_at_hash parameter; the Microsoft callback sets it to True on every response that carries an access_token, so a missing at_hash claim is a hard failure per OIDC Core §3.1.3.6. Previously the check was silently skipped when the claim was absent — spec-compliant only when no access_token was returned_exchange_code and _fetch_userinfo now detect the RFC 6750 / RFC 9470 challenge (WWW-Authenticate: Bearer …, error="insufficient_claims", claims="…"), raise ConditionalAccessError, and the callback redirects the browser to a fresh /authorize with the claims parameter attached — so the user can satisfy the CA policy (MFA, device compliance, managed identity attestation) and resume the original flow. The same challenge was already handled by the Graph connector for workload-identity calls; beta.30 extends coverage to interactive login. Mirrors on the /link callback too_resolve_provider now refuses to disable TLS verification (oidcVerifySsl=false) for non-localhost URLs unless the operator explicitly sets oidcAllowInsecureDev=true — the development escape hatch is gated to loopback and .local / .lan / .test / .localhost / .internal TLDs. After a successful OIDC Discovery fetch, the issuer field in the document must match the URL used to fetch it (RFC 8414 §3.3) — mismatches are now rejected instead of silently accepting whatever authorize and token endpoints the document advertisedpytest-xdist -n auto. Lifespan smoke on fresh HOME cleanauth_social.py audit. The prior flow reduced OIDC to "trust whatever email the userinfo endpoint returns over TLS" — a stolen access token, a compromised client secret, or a mis-scoped /common/ tenant all bypassed authentication. All five gaps map to ASVS 5.0 V6.1–V6.2 requirements an external NIS2 auditor checks/login and /link redirect; the verifier is bound to the session via the existing OAuth state map and sent on the callback token exchange. A 32-byte nonce is issued alongside every authorize-request and validated on the returned id_token with a constant-time comparisonserver/auth_oidc_verify.py module validates Entra id_tokens against the tenant's JWKS — RS256 signature, expected issuer, expected audience, exp/iat/nbf with 300s leeway, required-claim check, nonce binding, optional at_hash access-token binding, and optional tid allowlist. JWKS are cached 24h with automatic kid-miss refresh per Microsoft Learn guidance. Hand-rolled per ADR-008, no MSAL adoptiontenantId; the /common/, organizations, and consumers endpoints are rejected unless an operator opts into allowMultiTenant. An optional allowedTids allowlist is enforced against the id_token's tid claim — closes the "any Entra user anywhere can log in" oracle called out by the auditsocialProviders.<provider>.allowJitProvision (default false) across every provider. Combined with the Microsoft tid allowlist, this closes the account-creation oracle at auth_social.py:251 that the audit flagged as combining with /common/ into a trivial privilege-escalation vectorPyJWT>=2.12.0 added to all six pip install surfaces. Floor was chosen from PyPI latest + OSV advisories + NVD CPE cross-check: closes CVE-2026-32597 (CVSS 7.5 HIGH, crit header bypass, fixed in 2.12.0) and CVE-2024-53861 (CVSS 7.5 HIGH, iss partial-match, fixed in 2.10.1). The initial audit memo proposed >=2.9.0; verification caught that the proposed floor would have left both CVEs open/common/ toggle — matches the new backend config shapeat_hash enforcement where spec-required, Conditional Access insufficient_claims surface-through on the login path, generic-OIDC discovery issuer-match hardening) scheduled for beta.30 per the audit memo's tag-split plan; P3 items (front-/back-channel logout semantics, id_token jti replay cache, async-httpx conversion) for beta.31msgraph-sdk's Kiota middleware provides natively — the "keep hand-rolled" decision documented in ADR-008 now has feature parity on the observability side, closing the NIS2 audit-trail gap it createdopentelemetry-exporter-otlp pipeline light up the new instruments immediately.github/workflows/release.yml gated to GitHub Actions only — the 7 package-build jobs (version, agent-binaries, tarball, rpm, deb, arch, release) now skip cleanly on Gitea's self-hosted Act-runner instead of failing and burning CPU cycles that could take ~430 minutes per tag push on a 24-core hostNOBA_REDIS_URL is unset or unreachable — multi-worker deployments silently lose cross-worker throttle coordination without a shared cache, and in-memory fallback multiplies the 429 rate by the worker count under real migration loadredis:8-alpine with AOF persistence, a healthcheck, a named volume, and NOBA_REDIS_URL pre-wired — zero operator action for the default containerized pathredis>=7.4 declared across all six pip install surfaces (source dist, Docker image, installer script, three CI workflow venv setups). Verified clean against OSV (historical redis-py CVEs are all on the 4.x branch, fixed by 4.5.4) and NVD for the upstream Redis server image (8.6.2 is patched for CVE-2025-49844 Lua UAF CVSS 9.9 plus five more high-severity CVEs)docs/enterprise-setup.md gained a Redis requirement section with per-major-branch minimum-safe server versions for operators running their own cache; the three-way version-sync invariant (pyproject.toml + config.py + CHANGELOG.md) is now validated by the release workflow's extract-and-validate jobpython-multipart>=0.0.22 floor raised to close three CVEs that were live at the prior floor — CVE-2024-24762 (ReDoS in Content-Type header parsing affecting every FastAPI upload endpoint), CVE-2024-53981 (DoS via excessive boundary allocation), and CVE-2026-24486 / GHSA-wp53-j4wj-2cfg (arbitrary file write via boundary-crafted non-default filename handling). Applied across every pip install surface so fresh resolution cannot silently pick a vulnerable transitive versionPyMySQL>=1.1.1 floor raised to close CVE-2024-36039 (SQL injection via improper dict-key handling when binding JSON_TYPE parameters). Applied in the pyproject mysql extras, requirements-enterprise.txt, and the CI workflow's mysql-marked tests venvmsal / azure-identity / msgraph-sdk. Companion evidence pack at docs/audit/ contains verbatim quotes from Microsoft Learn, ENISA NIS2 Technical Implementation Guidance v1.0, OWASP ASVS 5.0 V6, OWASP A06:2021, NIST SP 800-204B, and CVE-2024-35255graph_throttle.py is strictly more capable than msgraph-sdk's Kiota middleware for NOBA's workload (pre-429 self-pace via x-ms-throttle-limit-percentage, token-bucket accounting against three published Graph rate limits, and cross-worker Redis coordination the SDK does not implement)Retry-After and x-ms-throttle-limit-percentage header classification, and a post-create replica-race wrapper now covers Azure groups, users, and Administrative Units (previously AU-only)confirm() dialog — accessible, theme-aware, and reachable from automation harnessesshare/noba-web/requirements.txt as the single source of truth for Python dependencies — the previous hand-maintained dep list had drifted out of sync and silently broke container startup when imports landed without the matching dep linenoba-agent.exe, registers it as a Windows service, and writes its config — no more "build from source" placeholdernoba-agent: 2.8 MB musl static binary across five crates, with self-update verified via SHA-256 + Ed25519, a protobuf agent protocol (JSON fallback for legacy agents), and cross-platform capture across X11 SHM, Wayland wlr-screencopy, kernel fbdev, and Windows DXGI + GDIX-Requested-By CSRF header and per-call HTTP clients for self-signed instancesexecute_op(), bringing the integration surface to zero placeholder modulesUNVERIFIED and unsupported boundaries where evidence still is not therecryptography to >= 46.0.7 to close the upstream GHSA exposure on Python > 3.11 and documented the reason inline in pyproject.tomllive, ad, and saml pytest markers so live-infra coverage no longer depends on loose keyword matchingrun_id, so operators can trace and cancel executions instead of receiving a null handle