Skip to content

Phase 18: Hardening & Final Build

Security review pass, rate limiting on public endpoints, and final verification that the docs site builds cleanly with all content pages.

All REST API inputs go through WP_REST_Request::sanitize_params() with explicit sanitize_callback per parameter. Example from ValidateController:

'license_token' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => fn($v) => is_string($v) && strlen($v) > 0,
],

Every repository query uses $wpdb->prepare(). The only exceptions are column name and ORDER BY direction strings, which are validated against an explicit allowlist before interpolation:

$allowed_cols = [ 'created_at', 'status', 'expires_at' ];
if ( ! in_array( $order_by, $allowed_cols, true ) ) {
$order_by = 'created_at';
}

All template output uses esc_html(), esc_attr(), esc_url(), or wp_kses_post() depending on context. No raw variable echoes anywhere in templates.

  • Admin form submissions: check_admin_referer() before processing $_POST.
  • My Account POST actions: wp_verify_nonce( $_POST['_wpnonce'], 'wplm_sub_action_' . $sub_id ).
  • AJAX endpoints: check_ajax_referer().

All admin action handlers check current_user_can('manage_options') (or a custom wplm_manage_licenses capability for non-admin staff roles).

Public endpoints (validate, activate, heartbeat) are rate-limited via transient counters keyed on wplm_rl_{ip}_{endpoint}:

$key = 'wplm_rl_' . md5( $ip ) . '_validate';
$count = (int) get_transient( $key );
if ( $count >= apply_filters( 'wplm_rate_limit_validate', 60 ) ) {
return new WP_REST_Response( [ 'code' => 'rate_limited' ], 429 );
}
set_transient( $key, $count + 1, 60 ); // 60-second window

Default limits (all filterable):

EndpointLimitWindow
/validate60 req60 s
/activate10 req60 s
/heartbeat120 req60 s

The Astro Starlight documentation site is located in docs-site/.

docsLoader() from @astrojs/starlight/loaders fails on Windows paths containing spaces. Resolved by replacing it with Astro’s built-in glob() loader in docs-site/src/content.config.ts:

import { glob } from 'astro/loaders';
loader: glob({ base: './src/content/docs', pattern: '**/*.{md,mdx}' })
Terminal window
cd docs-site
npm install
npm run build

Expected output: 20+ pages built to docs-site/dist/.

  • / — Introduction
  • /build-log/phase-01 through /build-log/phase-18
  • /guides/* — User guides
  • /rest-api/* — API reference
  • /php-api/* — PHP function reference

uninstall.php is guarded by WP_UNINSTALL_PLUGIN constant and:

  1. Checks wplm_keep_data_on_uninstall option — if truthy, exits without deleting anything.
  2. Drops all 15 wp_wplm_* tables via $wpdb->query("DROP TABLE IF EXISTS ...").
  3. Deletes all options with delete_option() for each registered option name.
  4. Clears any scheduled WP-Cron events: wp_clear_scheduled_hook('wplm_process_renewals'), wplm_cull_zombies.

At the end of Phase 18, the plugin is production-ready with:

  • ✓ 15-table schema, installed idempotently via dbDelta
  • ✓ Ed25519 signing + AES-256-GCM encryption
  • ✓ 7-step license validation with offline CRL support
  • ✓ Seat accounting with floating leases and zombie cull
  • ✓ Native subscription engine with dunning
  • ✓ 40+ REST endpoints with scoped API keys
  • ✓ Full WooCommerce integration (product panel, checkout delivery, My Account)
  • ✓ Admin UI (dashboard, list table, settings)
  • ✓ Signed webhooks with per-endpoint secrets
  • ✓ Anomaly detection (impossible travel, churn, spikes)
  • ✓ Documentation site (Astro Starlight)
  • ✓ Rate limiting on public endpoints
  • ✓ WordPress Coding Standards compliant