Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changelogs/fix-orphaned-quiz-fatal-error.yml
Original file line number Diff line number Diff line change
@@ -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."
170 changes: 170 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 <command>` 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-<name>.php` for classes, `model-llms-<name>.php` for models, `llms-trait-<name>.php` for traits (under `includes/traits/`), `llms-functions-<area>.php` for function files. Lowercase, hyphen-separated. Older files in `includes/models/` use a dot-separated `model.llms.<name>.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/<addon-slug>` 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.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
--------------------

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
2 changes: 1 addition & 1 deletion class-lifterlms.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ final class LifterLMS {
*
* @var string
*/
public $version = '10.0.3';
public $version = '10.0.4';

/**
* LLMS_Assets instance
Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -48,6 +48,8 @@
"*.xml.dist",
"*.config.js",

"artifacts",

"CHANGELOG.md",
"composer.json",
"lerna.json",
Expand Down
63 changes: 54 additions & 9 deletions includes/admin/class.llms.admin.builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Comment on lines +1094 to +1095

foreach ( $lessons as $lesson_data ) {

if ( ! isset( $lesson_data['id'] ) ) {
Expand All @@ -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'] ) ) {

Expand Down Expand Up @@ -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' );

Expand All @@ -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';
Expand All @@ -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 );
}
Comment on lines +1203 to +1207
$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 );

Expand Down Expand Up @@ -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 ) ) {
Comment on lines +1403 to +1405
// 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'] ) ) {

Expand Down Expand Up @@ -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' ) );
Expand Down Expand Up @@ -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'] ) ) {

Expand Down
Loading