From be242b3a2ab3e404a8208adcdfd25c5631885687 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 28 May 2026 22:00:57 -0400 Subject: [PATCH 1/6] Add AGENTS.md and CLAUDE.md for AI agent discoverability (#3163) AGENTS.md is the universal entry point for AI coding agents (Codex, Cursor, Aider, Claude Code, and others) pointed at this repo. It covers project shape, the bundled-package and add-on ecosystem, the contribution workflow (branch from dev, PR to dev, .changelogs entry), coding standards, public API discipline, verification rules, and a where-to-look quick reference. CLAUDE.md is a one-line import of AGENTS.md so Claude Code reads the same file without a duplicated source of truth. Pattern borrowed from WooCommerce. Includes a .changelogs entry per the contributing workflow. Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Jason Coleman <33220397+ideadude@users.noreply.github.com> --- .changelogs/agents-md-claude-md.yml | 3 + AGENTS.md | 170 ++++++++++++++++++++++++++++ CLAUDE.md | 1 + 3 files changed, 174 insertions(+) create mode 100644 .changelogs/agents-md-claude-md.yml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.changelogs/agents-md-claude-md.yml b/.changelogs/agents-md-claude-md.yml new file mode 100644 index 0000000000..41ceb70e17 --- /dev/null +++ b/.changelogs/agents-md-claude-md.yml @@ -0,0 +1,3 @@ +significance: patch +type: dev +entry: Add AGENTS.md and CLAUDE.md to surface project context, contribution workflow, and AI integration interfaces to AI coding agents working in the repo. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..93e6206f7d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,170 @@ +# LifterLMS for AI Agents + +LifterLMS is a WordPress learning management system plugin for creating, selling, and protecting online courses and membership sites. This file orients AI coding agents working in this repository. + +Interface-level documentation for driving a live LifterLMS site (CLI, MCP server, REST API) lives in [`docs/ai-agents.md`](docs/ai-agents.md). This file covers everything else: project shape, contribution workflow, coding rules, and verification discipline. + +## Project Shape + +LifterLMS is a single WordPress plugin with several bundled packages and a constellation of paid add-ons in separate repositories. + +### Core repository layout + +``` +lifterlms.php # WordPress plugin entry point +class-lifterlms.php # Main plugin class +includes/ # The bulk of the codebase (PHP source) + abstracts/ # Base classes + achievements/ # Achievement engine + admin/ # WordPress admin UI screens, metaboxes + certificates/ # Certificate engine + controllers/ # Form / order / quiz / lesson / award controllers + emails/ # Transactional email templates + forms/ # Form rendering and processing + functions/ # Procedural function files (llms-functions-*.php) + integrations/ # First-party integrations + interfaces/ # PHP interfaces + models/ # Domain models (course, lesson, student, order, ...) + notifications/ # Notification engine + privacy/ # GDPR exporters and erasers + processors/ # Background processors + schemas/ # JSON schemas + shortcodes/ # Front-end shortcodes + spam/ # Anti-spam helpers + theme-support/ # Twenty Twenty / Astra / OceanWP / etc. compat + traits/ # Reusable traits + widgets/ # Legacy WP widgets +src/ # Source files for compiled JS / (S)CSS + js/, blocks/, scss/ # Compile into assets/ — never commit compiled output +libraries/ # Vendored packages (populated by composer install) + lifterlms-rest/ # Installed from gocodebox/lifterlms-rest +packages/ # Internal monorepo packages (npm workspaces + lerna) + dev/ # Maintainer CLI: changelog, release, pot, readme + brand, components, icons, # Internal shared code + scripts, utils, fontawesome +docs/ # Contributor documentation + ai-agents.md # AI integration interfaces (CLI, MCP, REST) + coding-standards.md # snake_case, prefixes, file naming, formatting + documentation-standards.md # DocBlocks, @since, @deprecated tags + contributing.md # Mirror of .github/CONTRIBUTING.md + installing.md # Dev environment setup +templates/ # Front-end PHP templates (override-friendly) +tests/ + phpunit/ # PHPUnit tests + e2e/ # Playwright end-to-end tests +.changelogs/ # Pending changelog YAML entries (one per PR) +``` + +### Bundled packages with their own repositories + +These ship inside LifterLMS core releases via Composer but are developed in separate **public** repos: + +- [`lifterlms-blocks`](https://github.com/gocodebox/lifterlms-blocks) — Gutenberg blocks +- [`lifterlms-cli`](https://github.com/gocodebox/lifterlms-cli) — WP-CLI commands (`wp llms ...`) +- [`lifterlms-rest`](https://github.com/gocodebox/lifterlms-rest) — REST API endpoints (vendored into `libraries/lifterlms-rest/` during development setup) + +### The add-on ecosystem + +LifterLMS extends through paid add-ons published from **private** repositories under the `gocodebox` GitHub organization. Examples include Stripe, Groups, Private Areas, Events, Advanced Quizzes, Assignments, ConvertKit, Authorize.Net, PDFs, and integrations for Gravity Forms, Ninja Forms, Formidable Forms, WPForms, WooCommerce, and Zapier. The catalog is at https://lifterlms.com/store/. + +**Heads up:** add-on repos are private. Agents working on behalf of a LifterLMS team member with `gocodebox` org access can clone, read, and contribute to them. Agents working in the public open-source codebase will not be able to view them. If a hook, filter, or behavior originates in an add-on, the public-facing references are the product page and documentation at https://lifterlms.com/docs/. + +## AI Integration Interfaces + +Three ways for an agent to drive a live LifterLMS site: + +- **CLI** — `wp llms ` via the [`lifterlms-cli`](https://github.com/gocodebox/lifterlms-cli) package. For agents with shell access (Claude Code, Cursor, Codex on the server). +- **MCP server** — [`lifterlms-mcp`](https://github.com/gocodebox/lifterlms-mcp), built on the REST API. For agents without shell access (Claude Desktop, ChatGPT). +- **REST API** — `/wp-json/llms/v1/`, documented at https://developer.lifterlms.com/. + +Setup, command tables, and the "when to use what" decision matrix live in [`docs/ai-agents.md`](docs/ai-agents.md). + +**Directive:** when operating against a live LifterLMS site, prefer `wp llms` (with shell access) or the MCP server / REST API (without) over direct database writes or hand-rolled REST clients. The provided interfaces encode access controls, hooks, and side effects that direct writes bypass. + +## Contributing Workflow + +Authoritative source: [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md). The high-leverage rules: + +- **Branch from `dev`.** Cut new branches from the `dev` branch. +- **PR target: `dev`.** Never PR against `trunk`. Trunk holds the released version. +- **Reference issues with auto-link.** `Fixes #1234` or `Closes #1234` in the commit message and PR body where applicable. +- **Add a changelog entry.** From the repo root: + + ``` + npm run dev changelog add -- -i + ``` + + This drops a YAML file in `.changelogs/`. Pick the right significance (`patch`, `minor`, `major`) and type (`added`, `changed`, `fixed`, `deprecated`, `removed`, `dev`, `performance`, `security`). The entry compiles into the next release changelog automatically. +- **Source-only commits.** Edit source files in `src/`. Compiled and minified assets in `assets/` are gitignored build outputs and must not be committed. +- **Tests welcomed, not required.** Critical paths (enrollment, checkout, access plans, core models) deserve PHPUnit coverage; large user-facing flows deserve a Playwright E2E. PRs are not blocked on missing tests, but a change that breaks an existing test must fix or update it. + +### Preserving public API signatures + +Third-party plugins, add-ons, and customer sites depend on LifterLMS's public functions, methods, action names, filter names, and REST response shapes. Do not reorder parameters, rename arguments, or change return shapes on anything publicly accessible. + +- **Append**, do not reorder. New optional parameters with default values are safe. +- **Deprecate**, do not replace silently. If the existing signature cannot accommodate the change, leave the old function in place, mark it `@deprecated` with a pointer to the replacement, and create a new one. + +## Coding Standards (short version) + +Full rules in [`docs/coding-standards.md`](docs/coding-standards.md). The critical patterns: + +- **snake_case everywhere.** Class names, methods, functions, variables, hook names. +- **`LLMS_` class prefix** for core. Add-ons add a sub-prefix (`LLMS_AQ_*` for Advanced Quizzes, `LLMS_SL_*` for Social Learning, etc.). +- **`llms_` function and hook prefix** for core. New hooks use `llms_`; the legacy `lifterlms_` prefix is retained for back-compat only, not for new code. +- **`llms-` CSS class prefix** for front-end markup. +- **File naming.** `class-llms-.php` for classes, `model-llms-.php` for models, `llms-trait-.php` for traits (under `includes/traits/`), `llms-functions-.php` for function files. Lowercase, hyphen-separated. Older files in `includes/models/` use a dot-separated `model.llms..php` legacy pattern; new files should use the hyphenated form. +- **DocBlocks on everything new.** Summary line, then `@param` and `@return` with full sentences. See [`docs/documentation-standards.md`](docs/documentation-standards.md). + +## Verification Discipline + +LifterLMS is a mature codebase. Training data on it is often stale. Hook signatures evolve, add-ons get deprecated, REST shapes change between versions. + +Rules of the road for AI agents: + +1. **Code is canonical.** Before claiming a function, hook, filter, or class exists, grep `includes/` for it. Before describing how it behaves, read the source. +2. **Docs are second.** https://lifterlms.com/docs/ for user docs, https://developer.lifterlms.com/ for developer docs. +3. **Do not fabricate URLs.** Do not construct doc URLs from a name. If a doc page cannot be found through search or a doc index, say so. +4. **Customer assertions are unverified until grepped.** "I'm using the X shortcode" or "the bug is in the Y add-on" is a starting hypothesis, not a fact. +5. **Order status semantics are definitive.** `llms-pending` (initial state, awaiting transaction confirmation) and `llms-failed` (gateway sent an explicit failure event) are not interchangeable. A missing or misconfigured webhook produces `pending`, never `failed`. The valid statuses live in `includes/controllers/class.llms.controller.orders.php`. + +## Deprecated Add-Ons + +Some LifterLMS add-ons have been sunset. Do not recommend a deprecated add-on as a solution to a problem. + +This repo does not yet ship a canonical machine-readable list of deprecated add-ons. To verify whether an add-on is current: + +1. Check the official add-on directory at https://lifterlms.com/store/. +2. For LifterLMS team members with `gocodebox` org access: an archived `gocodebox/` repository signals deprecation. +3. When in doubt, ask in the `#developers` channel of the LifterLMS community Slack at https://lifterlms.com/slack, or open a question issue on this repo. + +> **TODO for the LifterLMS team.** Replace this section with an explicit, dated list of deprecated add-ons (slug, last shipped version, recommended replacement if any). A canonical list reduces support load and stops agents from suggesting sunset products to customers. + +## Reporting Security Vulnerabilities + +**Never** file a security vulnerability as a public GitHub issue. + +See [`.github/SECURITY.md`](.github/SECURITY.md) and https://lifterlms.com/security/ for the responsible disclosure process. If an agent discovers what looks like a vulnerability while working in this repo, surface it privately to the LifterLMS core team rather than opening a public issue or PR. + +## Where to Look for Things + +| Looking for... | Look in... | +|---|---| +| Course, lesson, quiz, membership, order models | `includes/models/` | +| Order, transaction, payment plan controllers | `includes/controllers/` | +| Front-end form rendering and processing | `includes/forms/` | +| Admin UI screens and metaboxes | `includes/admin/` | +| Hooks (actions / filters) | grep `includes/` for `do_action` or `apply_filters` | +| REST endpoints | the [`lifterlms-rest`](https://github.com/gocodebox/lifterlms-rest) repo, or `libraries/lifterlms-rest/` after `composer install` | +| CLI command shapes | the [`lifterlms-cli`](https://github.com/gocodebox/lifterlms-cli) repo | +| Block definitions | the [`lifterlms-blocks`](https://github.com/gocodebox/lifterlms-blocks) repo | +| Sample / fixture data | `sample-data/` | +| Front-end PHP templates | `templates/` | +| Build configuration | `webpack.config.js`, `gulpfile.js/`, `package.json` | +| Test scaffolding | `tests/phpunit/`, `tests/e2e/` | + +## Notes for AI Agents + +- This file is orientation. Procedural details live in `docs/` and `.github/CONTRIBUTING.md`. Read them. +- When uncertain about a hook, function, or behavior: read the code first, then ask. +- `CLAUDE.md` at the repo root imports this file, so Claude Code reads it on session start. No separate Anthropic-specific maintenance needed. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From c9c92169a078c47e6903331a3f483c37366e24c8 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Thu, 4 Jun 2026 10:38:12 -0400 Subject: [PATCH 2/6] Fix test --- .../class-llms-test-functions-certificates.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/phpunit/unit-tests/functions/class-llms-test-functions-certificates.php b/tests/phpunit/unit-tests/functions/class-llms-test-functions-certificates.php index 4310cb7ffe..160e932667 100644 --- a/tests/phpunit/unit-tests/functions/class-llms-test-functions-certificates.php +++ b/tests/phpunit/unit-tests/functions/class-llms-test-functions-certificates.php @@ -179,6 +179,15 @@ public function test_llms_get_certificate_content_reusable_blocks() { $reusable_pattern = '//'; + /** + * WordPress 7.0+ adds a `wp-block-paragraph` class to rendered paragraph blocks. + * Detect the rendered output so the expected snapshots match across WP versions. + */ + $paragraph_class = false !== strpos( + do_blocks( '

x

' ), + 'wp-block-paragraph' + ) ? ' class="wp-block-paragraph"' : ''; + foreach ( $template_posts as $key => $template_post ) { $reusable_key = $key < 100 ? $key : $key - 100; @@ -197,6 +206,10 @@ public function test_llms_get_certificate_content_reusable_blocks() { $expected = str_replace( '{certificate_id}', $template_post->ID, $expected ); $expected = str_replace( '{sequential_id}', $sequence_id, $expected ); + if ( '' !== $paragraph_class ) { + $expected = str_replace( '

', '', $expected ); + } + $this->assertEquals( $expected, llms_get_certificate_content( $template_post->ID ), From 5ad3e54145859c7a567e3de1fd2f28f240978e6d Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Thu, 4 Jun 2026 16:49:36 +0200 Subject: [PATCH 3/6] Extra check when updating data via the course builder (#3171) * Extra check when updating data via the course builder so content cannot be moved around different courses. * Additional checks to the course builder parent values. * Updating lifterlms-rest --- ...x_rest-api-and-course-builder-checks-1.yml | 5 + ...fix_rest-api-and-course-builder-checks.yml | 3 + composer.json | 2 +- includes/admin/class.llms.admin.builder.php | 63 +++- .../admin/class-llms-test-admin-builder.php | 278 ++++++++++++++++++ 5 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 .changelogs/fix_rest-api-and-course-builder-checks-1.yml create mode 100644 .changelogs/fix_rest-api-and-course-builder-checks.yml diff --git a/.changelogs/fix_rest-api-and-course-builder-checks-1.yml b/.changelogs/fix_rest-api-and-course-builder-checks-1.yml new file mode 100644 index 0000000000..412612fa6a --- /dev/null +++ b/.changelogs/fix_rest-api-and-course-builder-checks-1.yml @@ -0,0 +1,5 @@ +significance: patch +type: security +attributions: + - "@RegorSec" +entry: Additional checks on permissions with the REST API. diff --git a/.changelogs/fix_rest-api-and-course-builder-checks.yml b/.changelogs/fix_rest-api-and-course-builder-checks.yml new file mode 100644 index 0000000000..b5852b6710 --- /dev/null +++ b/.changelogs/fix_rest-api-and-course-builder-checks.yml @@ -0,0 +1,3 @@ +significance: patch +type: security +entry: Improved checks when saving Course Builder data. diff --git a/composer.json b/composer.json index 4bc51d56df..897ba2d0e9 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "lifterlms/lifterlms-blocks": "2.7.2", "lifterlms/lifterlms-cli": "0.0.5", "lifterlms/lifterlms-helper": "3.5.9", - "lifterlms/lifterlms-rest": "1.0.5", + "lifterlms/lifterlms-rest": "1.0.6", "woocommerce/action-scheduler": "3.5.4", "gocodebox/banner-notifications": "1.1.1" }, diff --git a/includes/admin/class.llms.admin.builder.php b/includes/admin/class.llms.admin.builder.php index b4a2b38196..a41cac41b8 100644 --- a/includes/admin/class.llms.admin.builder.php +++ b/includes/admin/class.llms.admin.builder.php @@ -1085,6 +1085,15 @@ private static function update_lessons( $lessons, $section, $course_id = 0 ) { $ret = array(); + /** + * Resolve the course these lessons must belong to and confirm the current user can edit it. + * + * Authorizing the target relationship here keeps the method self-contained rather than + * relying solely on a permission check performed earlier in the heartbeat request. + */ + $authorized_course_id = $course_id ? absint( $course_id ) : absint( $section->get( 'parent_course' ) ); + $can_edit_course = ! $authorized_course_id || current_user_can( 'edit_course', $authorized_course_id ); + foreach ( $lessons as $lesson_data ) { if ( ! isset( $lesson_data['id'] ) ) { @@ -1098,6 +1107,13 @@ private static function update_lessons( $lessons, $section, $course_id = 0 ) { ) ); + if ( ! $can_edit_course ) { + // Translators: %s = Lesson post id. + $res['error'] = sprintf( esc_html__( 'Unable to update lesson "%s". You are not allowed to edit the parent course.', 'lifterlms' ), $lesson_data['id'] ); + array_push( $ret, $res ); + continue; + } + // Create a new lesson. if ( self::is_temp_id( $lesson_data['id'] ) ) { @@ -1142,14 +1158,6 @@ private static function update_lessons( $lessons, $section, $course_id = 0 ) { // Don't create useless revision on "creating". add_filter( 'wp_revisions_to_keep', '__return_zero', 999 ); - /** - * If the parent section was just created the lesson will have a temp id - * replace it with the newly created section's real ID. - */ - if ( ! isset( $lesson_data['parent_section'] ) || self::is_temp_id( $lesson_data['parent_section'] ) ) { - $lesson_data['parent_section'] = $section->get( 'id' ); - } - // Return the real ID (important when creating a new lesson). $res['id'] = $lesson->get( 'id' ); @@ -1163,6 +1171,17 @@ private static function update_lessons( $lessons, $section, $course_id = 0 ) { $skip_props = apply_filters( 'llms_builder_update_lesson_skip_props', array( 'quiz' ) ); + /** + * Never trust client-supplied parent relationships. + * + * A lesson saved through the builder must belong to the authorized course and + * one of its sections. These props are skipped in the generic update loop and + * set explicitly below to prevent injecting or moving a lesson into a course + * the current user is not authorized to edit. + */ + $skip_props[] = 'parent_course'; + $skip_props[] = 'parent_section'; + // Don't overwrite content if the content editor doesn't display. if ( ! $created && '' !== $lesson->get( 'content' ) && ! llms_parse_bool( $lesson->get( 'content_added_in_builder' ) ) ) { $skip_props[] = 'content'; @@ -1181,6 +1200,14 @@ private static function update_lessons( $lessons, $section, $course_id = 0 ) { } } + // Force the lesson into the authorized course and section. + $lesson->set( 'parent_section', $section->get( 'id' ) ); + if ( $authorized_course_id ) { + $lesson->set( 'parent_course', $authorized_course_id ); + } + $res['parent_section'] = $lesson->get( 'parent_section' ); + $res['parent_course'] = $lesson->get( 'parent_course' ); + // Update all custom fields. self::update_custom_schemas( 'lesson', $lesson, $lesson_data ); @@ -1373,6 +1400,14 @@ private static function update_quiz( $quiz_data, $lesson, $course_id = 0 ) { ) ); + // Confirm the current user can edit the course this quiz belongs to, independent of earlier checks. + $authorized_course_id = $course_id ? absint( $course_id ) : absint( $lesson->get( 'parent_course' ) ); + if ( $authorized_course_id && ! current_user_can( 'edit_course', $authorized_course_id ) ) { + // Translators: %s = Quiz post id. + $res['error'] = sprintf( esc_html__( 'Unable to update quiz "%s". You are not allowed to edit the parent course.', 'lifterlms' ), $quiz_data['id'] ); + return $res; + } + // Create a quiz. if ( self::is_temp_id( $quiz_data['id'] ) ) { @@ -1436,11 +1471,14 @@ private static function update_quiz( $quiz_data, $lesson, $course_id = 0 ) { ); // Update all updatable properties. + // Never trust a client-supplied lesson_id; the quiz must belong to the authorized lesson. foreach ( $properties as $prop ) { - if ( isset( $quiz_data[ $prop ] ) ) { + if ( isset( $quiz_data[ $prop ] ) && 'lesson_id' !== $prop ) { $quiz->set( $prop, $quiz_data[ $prop ] ); } } + $quiz->set( 'lesson_id', $lesson->get( 'id' ) ); + $res['lesson_id'] = $lesson->get( 'id' ); // Include permalink and slug in the response so the builder can update the model. $res['permalink'] = get_permalink( $quiz->get( 'id' ) ); @@ -1477,6 +1515,13 @@ private static function update_section( $section_data, $course_id ) { ) ); + // Confirm the current user can edit the course this section belongs to, independent of earlier checks. + if ( $course_id && ! current_user_can( 'edit_course', absint( $course_id ) ) ) { + // Translators: %s = Section post id. + $res['error'] = sprintf( esc_html__( 'Unable to update section "%s". You are not allowed to edit the parent course.', 'lifterlms' ), $section_data['id'] ); + return $res; + } + // Create a new section. if ( self::is_temp_id( $section_data['id'] ) ) { diff --git a/tests/phpunit/unit-tests/admin/class-llms-test-admin-builder.php b/tests/phpunit/unit-tests/admin/class-llms-test-admin-builder.php index bea5444e5a..c00d1f38a9 100644 --- a/tests/phpunit/unit-tests/admin/class-llms-test-admin-builder.php +++ b/tests/phpunit/unit-tests/admin/class-llms-test-admin-builder.php @@ -546,6 +546,8 @@ private function check_title_content_filtering_on_save( $res, $sent ) { */ public function test_move_lesson_in_a_brand_new_section() { + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + // Create a Course with a Lesson. $course = $this->factory->course->create_and_get( array( 'sections' => 1, @@ -584,6 +586,282 @@ public function test_move_lesson_in_a_brand_new_section() { } + /** + * Test that a lesson cannot be moved into a course the builder is not authorized to edit. + * + * @since [version] + * + * @return void + */ + public function test_update_lessons_cannot_move_to_another_course() { + + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + + // Course the builder is editing, with a section + lesson. + $course_a = $this->factory->course->create_and_get( array( + 'sections' => 1, + 'lessons' => 1, + 'quizzes' => 0, + ) ); + $section_a = $course_a->get_sections()[0]; + $lesson_a = $course_a->get_lessons()[0]; + + // A different course with its own section. + $course_b = $this->factory->course->create_and_get( array( + 'sections' => 1, + 'lessons' => 0, + 'quizzes' => 0, + ) ); + $section_b = $course_b->get_sections()[0]; + + // Craft builder data attempting to move the lesson into course B / section B. + $lessons_data = array( + array( + 'id' => $lesson_a->get( 'id' ), + 'parent_course' => $course_b->get( 'id' ), + 'parent_section' => $section_b->get( 'id' ), + ), + ); + + LLMS_Unit_Test_Util::call_method( + $this->main, + 'update_lessons', + array( $lessons_data, $section_a, $course_a->get( 'id' ) ) + ); + + // The lesson must remain in the authorized course/section. + $lesson_a = llms_get_post( $lesson_a->get( 'id' ) ); + $this->assertEquals( $course_a->get( 'id' ), $lesson_a->get( 'parent_course' ) ); + $this->assertEquals( $section_a->get( 'id' ), $lesson_a->get_parent_section() ); + } + + /** + * Test that a newly created lesson cannot be injected into another course via the builder. + * + * @since [version] + * + * @return void + */ + public function test_update_lessons_new_lesson_cannot_inject_into_another_course() { + + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + + // Course being edited, with a section. + $course_a = $this->factory->course->create_and_get( array( + 'sections' => 1, + 'lessons' => 0, + 'quizzes' => 0, + ) ); + $section_a = $course_a->get_sections()[0]; + + // A different course with its own section. + $course_b = $this->factory->course->create_and_get( array( + 'sections' => 1, + 'lessons' => 0, + 'quizzes' => 0, + ) ); + $section_b = $course_b->get_sections()[0]; + + $lessons_data = array( + array( + 'id' => 'temp_1', + 'title' => 'New lesson', + 'parent_course' => $course_b->get( 'id' ), + 'parent_section' => $section_b->get( 'id' ), + ), + ); + + $res = LLMS_Unit_Test_Util::call_method( + $this->main, + 'update_lessons', + array( $lessons_data, $section_a, $course_a->get( 'id' ) ) + ); + + $new_lesson = llms_get_post( $res[0]['id'] ); + + // The new lesson must belong to the authorized course/section, not course B. + $this->assertEquals( $course_a->get( 'id' ), $new_lesson->get( 'parent_course' ) ); + $this->assertEquals( $section_a->get( 'id' ), $new_lesson->get_parent_section() ); + $this->assertEmpty( $course_b->get_lessons() ); + } + + /** + * Test that a quiz's lesson_id is forced to the authorized lesson and cannot be pointed elsewhere. + * + * @since [version] + * + * @return void + */ + public function test_update_quiz_forces_lesson_id_to_authorized_lesson() { + + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + + $course = $this->factory->course->create_and_get( array( + 'sections' => 1, + 'lessons' => 1, + 'quizzes' => 0, + ) ); + $lesson = $course->get_lessons()[0]; + + // A lesson outside the builder context that the quiz must not be pointed at. + $other_lesson_id = $this->factory->post->create( array( 'post_type' => 'lesson' ) ); + + $quiz_data = array( + 'id' => 'temp_1', + 'title' => 'Quiz', + 'lesson_id' => $other_lesson_id, + ); + + $res = LLMS_Unit_Test_Util::call_method( + $this->main, + 'update_quiz', + array( $quiz_data, $lesson, $course->get( 'id' ) ) + ); + + $quiz = llms_get_post( $res['id'] ); + $this->assertEquals( $lesson->get( 'id' ), $quiz->get( 'lesson_id' ) ); + } + + /** + * Test that a user who can only edit one course cannot use a builder heartbeat to move or + * inject lessons into a different course they are not allowed to edit. + * + * @since [version] + * + * @return void + */ + public function test_heartbeat_cannot_move_or_inject_lessons_into_unauthorized_course() { + + $user_with_access = $this->factory->user->create( array( 'role' => 'instructor' ) ); + $user_without_access = $this->factory->user->create( array( 'role' => 'instructor' ) ); + + // Course B is owned by a different user. + wp_set_current_user( $user_without_access ); + $course_b = $this->factory->course->create_and_get( array( + 'sections' => 1, + 'lessons' => 0, + 'quizzes' => 0, + ) ); + $section_b = $course_b->get_sections()[0]; + + // Course A is owned by the user performing the save. + wp_set_current_user( $user_with_access ); + $course_a = $this->factory->course->create_and_get( array( + 'sections' => 1, + 'lessons' => 1, + 'quizzes' => 0, + ) ); + $section_a = $course_a->get_sections()[0]; + $lesson_a = $course_a->get_lessons()[0]; + + // The privilege boundary this test depends on. + $this->assertTrue( current_user_can( 'edit_course', $course_a->get( 'id' ) ) ); + $this->assertFalse( current_user_can( 'edit_course', $course_b->get( 'id' ) ) ); + + // Heartbeat for course A that attempts to move the existing lesson and inject a new one into course B. + $builder_data = array( + 'id' => $course_a->get( 'id' ), + 'updates' => array( + 'id' => $course_a->get( 'id' ), + 'sections' => array( + array( + 'id' => $section_a->get( 'id' ), + 'lessons' => array( + array( + 'id' => $lesson_a->get( 'id' ), + 'parent_course' => $course_b->get( 'id' ), + 'parent_section' => $section_b->get( 'id' ), + ), + array( + 'id' => 'temp_1', + 'title' => 'New lesson', + 'parent_course' => $course_b->get( 'id' ), + 'parent_section' => $section_b->get( 'id' ), + ), + ), + ), + ), + ), + ); + + $res = LLMS_Unit_Test_Util::call_method( + $this->main, + 'heartbeat_received', + array( array(), array( 'llms_builder' => wp_json_encode( $builder_data ) ) ) + ); + + $this->assertEquals( 'success', $res['llms_builder']['status'] ); + + // The existing lesson stays in course A. + $lesson_a = llms_get_post( $lesson_a->get( 'id' ) ); + $this->assertEquals( $course_a->get( 'id' ), $lesson_a->get( 'parent_course' ) ); + $this->assertEquals( $section_a->get( 'id' ), $lesson_a->get_parent_section() ); + + // Course B gains no lessons from the crafted request. + $this->assertEmpty( $course_b->get_lessons() ); + } + + /** + * Test that update_section refuses to write into a course the current user cannot edit. + * + * @since [version] + * + * @return void + */ + public function test_update_section_requires_edit_course_capability() { + + $owner = $this->factory->user->create( array( 'role' => 'instructor' ) ); + wp_set_current_user( $owner ); + $course = $this->factory->course->create_and_get( array( + 'sections' => 0, + 'lessons' => 0, + 'quizzes' => 0, + ) ); + + // A different user without access to the course attempts the save. + wp_set_current_user( $this->factory->user->create( array( 'role' => 'instructor' ) ) ); + + $res = LLMS_Unit_Test_Util::call_method( + $this->main, + 'update_section', + array( array( 'id' => 'temp_1', 'title' => 'New section' ), $course->get( 'id' ) ) + ); + + $this->assertArrayHasKey( 'error', $res ); + $this->assertEmpty( $course->get_sections() ); + } + + /** + * Test that update_quiz refuses to write into a course the current user cannot edit. + * + * @since [version] + * + * @return void + */ + public function test_update_quiz_requires_edit_course_capability() { + + $owner = $this->factory->user->create( array( 'role' => 'instructor' ) ); + wp_set_current_user( $owner ); + $course = $this->factory->course->create_and_get( array( + 'sections' => 1, + 'lessons' => 1, + 'quizzes' => 0, + ) ); + $lesson = $course->get_lessons()[0]; + + // A different user without access to the course attempts the save. + wp_set_current_user( $this->factory->user->create( array( 'role' => 'instructor' ) ) ); + + $res = LLMS_Unit_Test_Util::call_method( + $this->main, + 'update_quiz', + array( array( 'id' => 'temp_1', 'title' => 'New quiz' ), $lesson, $course->get( 'id' ) ) + ); + + $this->assertArrayHasKey( 'error', $res ); + $this->assertFalse( $lesson->is_quiz_enabled() ); + } + /** * Catch wp_die() called by ajax methods & store the output buffer contents for use later. * From 6f6dbb320c2d83e2d8422efd7c3374b87c343100 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Thu, 4 Jun 2026 10:57:58 -0400 Subject: [PATCH 4/6] Build for 10.0.4. --- .changelogs/agents-md-claude-md.yml | 3 --- .../fix_rest-api-and-course-builder-checks-1.yml | 5 ----- .../fix_rest-api-and-course-builder-checks.yml | 3 --- CHANGELOG.md | 13 +++++++++++++ class-lifterlms.php | 2 +- lifterlms.php | 2 +- package-lock.json | 2 +- package.json | 2 +- .../admin/class-llms-test-admin-builder.php | 12 ++++++------ 9 files changed, 23 insertions(+), 21 deletions(-) delete mode 100644 .changelogs/agents-md-claude-md.yml delete mode 100644 .changelogs/fix_rest-api-and-course-builder-checks-1.yml delete mode 100644 .changelogs/fix_rest-api-and-course-builder-checks.yml diff --git a/.changelogs/agents-md-claude-md.yml b/.changelogs/agents-md-claude-md.yml deleted file mode 100644 index 41ceb70e17..0000000000 --- a/.changelogs/agents-md-claude-md.yml +++ /dev/null @@ -1,3 +0,0 @@ -significance: patch -type: dev -entry: Add AGENTS.md and CLAUDE.md to surface project context, contribution workflow, and AI integration interfaces to AI coding agents working in the repo. diff --git a/.changelogs/fix_rest-api-and-course-builder-checks-1.yml b/.changelogs/fix_rest-api-and-course-builder-checks-1.yml deleted file mode 100644 index 412612fa6a..0000000000 --- a/.changelogs/fix_rest-api-and-course-builder-checks-1.yml +++ /dev/null @@ -1,5 +0,0 @@ -significance: patch -type: security -attributions: - - "@RegorSec" -entry: Additional checks on permissions with the REST API. diff --git a/.changelogs/fix_rest-api-and-course-builder-checks.yml b/.changelogs/fix_rest-api-and-course-builder-checks.yml deleted file mode 100644 index b5852b6710..0000000000 --- a/.changelogs/fix_rest-api-and-course-builder-checks.yml +++ /dev/null @@ -1,3 +0,0 @@ -significance: patch -type: security -entry: Improved checks when saving Course Builder data. diff --git a/CHANGELOG.md b/CHANGELOG.md index 769a3421ff..8a43d1c4d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ LifterLMS Changelog =================== +v10.0.4 - 2026-06-04 +-------------------- + +##### Developer Notes + ++ Add AGENTS.md and CLAUDE.md to surface project context, contribution workflow, and AI integration interfaces to AI coding agents working in the repo. + +##### Security Fixes + ++ Additional checks on permissions with the REST API. Thanks [@RegorSec](https://github.com/RegorSec)! ++ Improved checks when saving Course Builder data. + + v10.0.3 - 2026-05-19 -------------------- diff --git a/class-lifterlms.php b/class-lifterlms.php index d0eb4af5a0..177d129826 100644 --- a/class-lifterlms.php +++ b/class-lifterlms.php @@ -34,7 +34,7 @@ final class LifterLMS { * * @var string */ - public $version = '10.0.3'; + public $version = '10.0.4'; /** * LLMS_Assets instance diff --git a/lifterlms.php b/lifterlms.php index 0b0f052a45..319071a953 100644 --- a/lifterlms.php +++ b/lifterlms.php @@ -10,7 +10,7 @@ * Plugin Name: LifterLMS * Plugin URI: https://lifterlms.com/ * Description: Complete e-learning platform to sell online courses, protect lessons, offer memberships, and quiz students. WP Learning Management System. - * Version: 10.0.3 + * Version: 10.0.4 * Author: LifterLMS * Author URI: https://lifterlms.com/ * Text Domain: lifterlms diff --git a/package-lock.json b/package-lock.json index c3c25ec118..4c771c492f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "lifterlms", - "version": "10.0.3", + "version": "10.0.4", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 0a4fd2da19..23c8eab15e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lifterlms", - "version": "10.0.3", + "version": "10.0.4", "description": "LifterLMS by codeBOX", "repository": { "type": "git", diff --git a/tests/phpunit/unit-tests/admin/class-llms-test-admin-builder.php b/tests/phpunit/unit-tests/admin/class-llms-test-admin-builder.php index c00d1f38a9..252f249adf 100644 --- a/tests/phpunit/unit-tests/admin/class-llms-test-admin-builder.php +++ b/tests/phpunit/unit-tests/admin/class-llms-test-admin-builder.php @@ -589,7 +589,7 @@ public function test_move_lesson_in_a_brand_new_section() { /** * Test that a lesson cannot be moved into a course the builder is not authorized to edit. * - * @since [version] + * @since 10.0.4 * * @return void */ @@ -638,7 +638,7 @@ public function test_update_lessons_cannot_move_to_another_course() { /** * Test that a newly created lesson cannot be injected into another course via the builder. * - * @since [version] + * @since 10.0.4 * * @return void */ @@ -688,7 +688,7 @@ public function test_update_lessons_new_lesson_cannot_inject_into_another_course /** * Test that a quiz's lesson_id is forced to the authorized lesson and cannot be pointed elsewhere. * - * @since [version] + * @since 10.0.4 * * @return void */ @@ -726,7 +726,7 @@ public function test_update_quiz_forces_lesson_id_to_authorized_lesson() { * Test that a user who can only edit one course cannot use a builder heartbeat to move or * inject lessons into a different course they are not allowed to edit. * - * @since [version] + * @since 10.0.4 * * @return void */ @@ -804,7 +804,7 @@ public function test_heartbeat_cannot_move_or_inject_lessons_into_unauthorized_c /** * Test that update_section refuses to write into a course the current user cannot edit. * - * @since [version] + * @since 10.0.4 * * @return void */ @@ -834,7 +834,7 @@ public function test_update_section_requires_edit_course_capability() { /** * Test that update_quiz refuses to write into a course the current user cannot edit. * - * @since [version] + * @since 10.0.4 * * @return void */ From e9f26736e8d884c1650569847ec105f7de5d9ff7 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Thu, 4 Jun 2026 11:02:42 -0400 Subject: [PATCH 5/6] Exclude artifacts from playwright in the zip. --- composer.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/composer.json b/composer.json index 897ba2d0e9..db672b46b9 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,8 @@ "*.xml.dist", "*.config.js", + "artifacts", + "CHANGELOG.md", "composer.json", "lerna.json", From 11f5ade27d20fe3a990e432a77b562d1d901dafa Mon Sep 17 00:00:00 2001 From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:48:47 +0000 Subject: [PATCH 6/6] Fix fatal error when quiz's parent lesson has been deleted When a quiz has a stored `lesson_id` in post meta but the referenced lesson post no longer exists, `llms_get_post( $lesson_id )` returns false. Two access-check functions didn't guard against this: `llms_is_post_restricted_by_prerequisite()`: called `$lesson->get_course()` immediately after `llms_get_post()` with no null-check, causing a fatal error / TypeError on any page-restriction check for that quiz. `llms_is_post_restricted_by_time_period()`: had a copy-paste bug where the guard read `if ( ! $lesson_id )` instead of `if ( ! $lesson )`. Since `$lesson_id` was already confirmed truthy one line above, the guard was dead code and `$lesson->get( 'parent_course' )` was unprotected. Both functions now return `false` (no restriction found) when `llms_get_post()` returns a falsy value for the lesson, consistent with how the function handles other not-found cases. Reproduction: assign a quiz to a lesson, delete the lesson post, then visit the quiz URL as an enrolled student. --- .changelogs/fix-orphaned-quiz-fatal-error.yml | 7 +++++++ includes/functions/llms.functions.access.php | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .changelogs/fix-orphaned-quiz-fatal-error.yml diff --git a/.changelogs/fix-orphaned-quiz-fatal-error.yml b/.changelogs/fix-orphaned-quiz-fatal-error.yml new file mode 100644 index 0000000000..79656c4d70 --- /dev/null +++ b/.changelogs/fix-orphaned-quiz-fatal-error.yml @@ -0,0 +1,7 @@ +significance: patch +type: security +comment: 'Two guards in llms.functions.access.php that fire when a quiz''s parent lesson no longer exists (deleted/trashed). One was missing entirely; one was a copy-paste error checking $lesson_id (already confirmed truthy) instead of $lesson after llms_get_post().' +links: [] +attributions: + - '@thisismyurl' +entry: "Fix fatal error when accessing a quiz whose parent lesson has been deleted. `llms_is_post_restricted_by_prerequisite()` was missing a null-check on the `llms_get_post()` result before calling `->get_course()`. `llms_is_post_restricted_by_time_period()` had a copy-paste bug where the guard re-tested `$lesson_id` (already truthy) instead of `$lesson` after the same `llms_get_post()` call, rendering it dead code and leaving `->get( 'parent_course' )` unprotected." diff --git a/includes/functions/llms.functions.access.php b/includes/functions/llms.functions.access.php index 4d26ab2cae..1b449d4aa1 100644 --- a/includes/functions/llms.functions.access.php +++ b/includes/functions/llms.functions.access.php @@ -364,6 +364,7 @@ function llms_is_post_restricted_by_drip_settings( $post_id, $user_id = null ) { * @since 3.0.0 * @since 3.16.11 Unknown. * @since 6.5.0 Improve code readability turning if-elseif into a switch-case. + * @since $$next-version$$ Guard against a fatal error when the quiz's parent lesson no longer exists. * * @param int $post_id WP Post ID of a lesson or quiz. * @param int|null $user_id Optional. WP User ID (will use get_current_user_id() if none supplied). Default `null`. @@ -395,6 +396,10 @@ function llms_is_post_restricted_by_prerequisite( $post_id, $user_id = null ) { } $lesson = llms_get_post( $lesson_id ); + if ( ! $lesson ) { + return false; + } + $course = $lesson->get_course(); if ( ! $course ) { @@ -452,6 +457,7 @@ function llms_is_post_restricted_by_prerequisite( $post_id, $user_id = null ) { * @since 3.16.11 Unknown. * @since 5.7.0 Replaced the call to the deprecated `LLMS_Lesson::get_parent_course()` method with `LLMS_Lesson::get( 'parent_course' )`. * @since 6.5.0 Improve code readability turning if-elseif into a switch-case. + * @since $$next-version$$ Fix copy-paste error: guard checked `$lesson_id` (already truthy) instead of `$lesson` after `llms_get_post()`. * * @param int $post_id WP Post ID of a course, lesson, or quiz. * @param int|null $user_id Optional. WP User ID (will use get_current_user_id() if none supplied). Default `null`. @@ -475,7 +481,7 @@ function llms_is_post_restricted_by_time_period( $post_id, $user_id = null ) { return false; } $lesson = llms_get_post( $lesson_id ); - if ( ! $lesson_id ) { + if ( ! $lesson ) { return false; } $course_id = $lesson->get( 'parent_course' );