Security follow-up to v1.5.1. Closes four items from the v1.5.1 review — two ship-blockers and two mediums — all within the ADR-0001 envelope. PSEC-007 — peptide_starter_get_client_ip() now trusts CF-Connecting-IP only when REMOTE_ADDR falls inside a published Cloudflare edge range. Previously, any direct-to-origin request (which Hostinger permits) could forge its rate-limit key by setting the header to a different IP per attempt, bypassing every abuse control in the theme. New module inc/cloudflare-ips.php embeds a dated snapshot of CF v4/v6 ranges and a CIDR matcher covering both families; list is overridable via the peptide_starter_cloudflare_ip_ranges filter for out-of-band updates. HTTP_X_FORWARDED_FOR stays ignored unless operators opt in via the peptide_starter_trust_xff filter. PSEC-008 — Removed the v1.5.1 user-verification migration (Option A as confirmed by peptiderepo). The sync-wp_mail-per-subscriber loop on admin_init would have blocked wp-admin and hammered deliverability. Existing accounts are now grandfathered: no ps_pending_verification meta = verified. Verification applies only to registrations from v1.5.2 forward. The ps_verify_migration_version option (written by v1.5.1) is cleared on theme activation. PSEC-009 — Registration validation no longer short-circuits. All five checks (username pattern, email validity, password length, username_exists, email_exists) evaluate unconditionally before the combined rejection. Closes the response-time oracle that let an attacker distinguish microsecond-fast regex rejection from DB-round-trip rejection and infer which field triggered the failure. PSEC-010 — Added the happy-path verify test that v1.5.1 was missing. Covers token match, user meta cleanup, auth cookie set, and the peptide_starter_user_verified action firing with the correct user ID. Seven new rate-limiter tests confirm CF-peer validation works end-to-end: trusted CF peer honours the header, untrusted peer ignores spoofed headers, invalid values fall through, XFF default-off, XFF filter opt-in, CF range filter override, IPv6 CIDR match, family mismatch rejection. Confirmed with peptiderepo before coding: (1) Option A for PSEC-008 because zero users; (2) Cloudflare IS in front of the origin (dig confirms 104.21.x / 172.67.x + CF-Ray response header), so PSEC-007 is a real fix not dead code; (3) no staging — treat prod as staging. ARCHITECTURE.md gains a client-IP trust model section; CONVENTIONS.md documents the header-trust rules for future modules. Version bumped to 1.5.2 in style.css and functions.php. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
20 KiB
Peptide Starter Theme - Conventions & Development Guide
Naming Conventions
PHP Functions
All custom PHP functions are prefixed with peptide_starter_:
// Good
function peptide_starter_get_hero_title() { }
function peptide_starter_setup() { }
function peptide_starter_widgets_init() { }
function peptide_starter_pagination() { }
// Bad
function get_hero_title() { }
function setup_theme() { }
Pattern: peptide_starter_{verb}_{noun} or peptide_starter_get_{option}
Common function types:
peptide_starter_get_*()— Retrieve theme mod or optionpeptide_starter_{noun}_init()— Initialization functionspeptide_starter_is_*()— Boolean checkspeptide_starter_render_*()— Output HTML
CSS Classes
All theme CSS classes are prefixed with .ps-:
/* Components */
.ps-btn /* Button component */
.ps-btn-primary /* Button variant */
.ps-btn-sm /* Button size */
.ps-card /* Card component */
.ps-badge /* Badge component */
.ps-alert-info /* Alert variant */
/* Utilities */
.ps-container /* Max-width wrapper */
.ps-pagination /* Pagination nav */
.ps-search-form /* Search form */
.ps-search-input /* Search input */
/* Regions */
.site-header /* No prefix; global region */
.site-footer /* No prefix; global region */
.site-main /* No prefix; global region */
.hero-section /* Hero region specific */
Pattern: .ps-{component} for components, .ps-{component}-{variant} for variants
Reserved prefixes (do not use):
.pn-*— Peptide News plugin.psa-*— Peptide Search AI plugin.prab-*— PRAutoBlogger plugin
CSS Variables (Custom Properties)
All design tokens are defined in style.css :root:
/* Colors */
--text-primary /* Main text color */
--text-secondary /* Secondary text color */
--bg-primary /* Main background */
--bg-secondary /* Secondary background */
/* Spacing (8px grid base) */
--spacing-xs: 4px /* 0.5 × base */
--spacing-sm: 8px /* 1 × base */
--spacing-md: 16px /* 2 × base */
--spacing-lg: 24px /* 3 × base */
--spacing-xl: 32px /* 4 × base */
/* Typography */
--font-sans /* Primary font family */
--font-mono /* Monospace font */
--text-h1 /* H1 size */
--text-h1-weight /* H1 weight */
Usage:
.ps-card {
padding: var(--spacing-md);
color: var(--text-primary);
}
Template Part Naming
Template parts in template-parts/ follow WordPress convention:
template-parts/content.php /* Default loop item */
template-parts/content-{post-type}.php /* CPT-specific */
template-parts/content-none.php /* Empty state */
template-parts/{section}-{name}.php /* Specific parts (if added) */
Calling template parts:
get_template_part( 'template-parts/content', get_post_type() );
get_template_part( 'template-parts/content', 'none' );
Hook Names
Custom hooks use the peptide_starter_ prefix:
// Filters
apply_filters( 'peptide_starter_show_newsletter', true );
// Actions (if any added in future)
do_action( 'peptide_starter_hero_before' );
File Organization
- Root: Theme core files (functions.php, header.php, footer.php, etc.)
- assets/js/: Client-side scripts only; no bundling
- assets/css/: (None; all CSS in style.css)
- languages/: Translation files
- template-parts/: Reusable template parts
Step-by-Step Guides
How to Add a New Template
Scenario: Create a custom landing page template
-
Create file:
landing-{name}.php<?php /** * Landing Page: {Description} * Template Name: {Display Name} * * @package peptide-starter */ get_header(); ?> <main id="main" class="site-main"> <!-- Your content here --> </main> <?php get_footer(); -
Add template header comment:
/** * Template Name: My Custom Landing Page * Template Post Type: page */ -
The template will appear in WordPress Page > Template dropdown
-
Test the template:
- Create/edit a page and select the template
- Verify hero, dark mode toggle, footer all appear correctly
Best practices:
- Always include
get_header()andget_footer() - Wrap main content in
<main id="main" class="site-main"> - Use
.ps-containerfor consistent max-width - Use existing component classes (
.ps-card,.ps-btn, etc.) - Keep templates under 150 lines; extract complex parts to
template-parts/
How to Add a New Plugin Integration
Scenario: Integrate a new plugin (e.g., "Research Database") via shortcode
-
In front-page.php, add a conditional shortcode block:
<?php if ( shortcode_exists( 'research_db' ) ) { ?> <section class="research-db-section"> <div class="ps-container"> <h2><?php esc_html_e( 'Research Database', 'peptide-starter' ); ?></h2> <?php echo do_shortcode( '[research_db limit="10"]' ); ?> </div> </section> <?php } ?> -
In functions.php, optionally add inline overrides if plugin CSS needs adjustment:
function peptide_starter_plugin_overrides() { wp_add_inline_style( 'peptide-starter-style', ' /* Override plugin styles here */ .rdb-card { /* example */ } ' ); } add_action( 'wp_enqueue_scripts', 'peptide_starter_plugin_overrides', 99 ); -
Document the integration in CLAUDE.md:
- Plugin namespace (e.g.,
.rdb-*) - Shortcode name and parameters
- Expected styling classes
- Plugin namespace (e.g.,
-
If plugin needs dark mode coordination:
- Ask plugin author to listen to
themechangeevent (see ARCHITECTURE.md) - Or have plugin read
html[data-theme]attribute - Provide plugin with CSS custom properties:
getComputedStyle(document.documentElement).getPropertyValue('--text-primary')
- Ask plugin author to listen to
-
Test:
- Activate plugin and visit front page
- Verify shortcode renders
- Toggle dark mode; verify plugin responds
- Deactivate plugin; verify page still renders (fallback gracefully)
Important: Always wrap shortcodes in shortcode_exists() checks so theme doesn't break if plugin is missing.
How to Add a New CSS Component
Scenario: Create a new "pill" button style (small, rounded, inline)
-
In style.css, find the Components section (around line 500)
-
Add new component class:
/* Pill buttons */ .ps-btn-pill { display: inline-block; padding: var(--spacing-xs) var(--spacing-sm); border-radius: 9999px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } .ps-btn-pill.ps-btn-primary { background-color: var(--color-primary); color: white; } .ps-btn-pill.ps-btn-secondary { background-color: transparent; border: 1px solid var(--color-primary); color: var(--color-primary); } -
Add dark mode support (in Dark Mode section, around line 1200):
html[data-theme="dark"] .ps-btn-pill.ps-btn-secondary { border-color: var(--color-primary-dark); color: var(--color-primary-dark); } -
Add responsive sizes if needed (in Responsive section, around line 1400):
@media (max-width: 767px) { .ps-btn-pill { padding: var(--spacing-xs) var(--spacing-xs); } } -
Usage in templates:
<span class="ps-btn ps-btn-pill ps-btn-primary">Beta</span> -
Test:
- Light mode: verify colors, sizing, alignment
- Dark mode: toggle and verify contrast
- Mobile (480px): verify responsive adjustments
- Keyboard: focus state should be visible
Best practices:
- Use CSS variables (
var(--spacing-*),var(--color-*)) not hardcoded values - Avoid
!importantunless absolutely necessary - Include dark mode support immediately (no
.ps-*is light-only) - Group related rules with comments
- Keep component definitions self-contained (<100 lines)
How to Add a New Customizer Setting
Scenario: Add a new "Primary Accent Color" customizer option
-
In functions.php (around line 143), add a new section:
$wp_customize->add_section( 'peptide_starter_colors', array( 'title' => esc_html__( 'Colors', 'peptide-starter' ), 'priority' => 45, ) ); -
Add a setting:
$wp_customize->add_setting( 'primary_accent_color', array( 'default' => '#0066CC', 'transport' => 'postMessage', 'sanitize_callback' => 'sanitize_hex_color', ) ); -
Add a control:
$wp_customize->add_control( new WP_Customize_Color_Control( $wp_customize, 'primary_accent_color', array( 'label' => esc_html__( 'Primary Accent Color', 'peptide-starter' ), 'section' => 'peptide_starter_colors', 'settings' => 'primary_accent_color', ) ) ); -
Create a getter function:
function peptide_starter_get_primary_accent_color() { return get_theme_mod( 'primary_accent_color', '#0066CC' ); } -
Output in front-end (e.g., in footer.php):
<style> :root { --color-accent: <?php echo esc_attr( peptide_starter_get_primary_accent_color() ); ?>; } </style>Or better, use inline style in
wp_head:function peptide_starter_custom_colors() { $accent = peptide_starter_get_primary_accent_color(); ?> <style> :root { --color-accent: <?php echo esc_attr( $accent ); ?>; } </style> <?php } add_action( 'wp_head', 'peptide_starter_custom_colors' ); -
Reference in CSS:
.ps-btn-accent { background-color: var(--color-accent); } -
Test:
- Appearance → Customize → Colors → Primary Accent Color
- Change color and verify live preview
- Save and reload page
Transport options:
'transport' => 'postMessage'— Instant preview (needs JS listener)'transport' => 'refresh'— Full page refresh
For color/text changes, use postMessage with a JS listener in theme.js:
wp_customize.bind( 'setting:primary_accent_color', function( setting ) {
setting.bind( function( newval ) {
document.documentElement.style.setProperty('--color-accent', newval);
});
});
Error Handling Patterns
Theme Doesn't Break If Plugin Missing
Pattern: Graceful Degradation
// Good — shortcode_exists check
if ( shortcode_exists( 'peptide_search' ) ) {
echo do_shortcode( '[peptide_search]' );
} else {
get_search_form(); // Fallback to WordPress default
}
// Bad — assumes plugin exists
echo do_shortcode( '[peptide_search]' ); // Renders [peptide_search] as text if missing
Menu Fallback
In header.php:
wp_nav_menu( array(
'theme_location' => 'primary',
'fallback_cb' => 'peptide_starter_primary_menu_fallback', // Function defined in header
'walker' => new Peptide_Starter_Nav_Walker(),
) );
function peptide_starter_primary_menu_fallback() {
// Renders Home, About, Peptides links if no menu assigned
}
Widget Area Check
In footer.php:
if ( is_active_sidebar( 'footer-1' ) ) {
dynamic_sidebar( 'footer-1' );
} else {
// Render default content
echo '<h3>' . esc_html__( 'About', 'peptide-starter' ) . '</h3>';
}
Class Existence Check
For WooCommerce:
if ( class_exists( 'WooCommerce' ) ) {
// Render cart icon
}
For post types:
if ( post_type_exists( 'peptide' ) ) {
echo '<a href="' . get_post_type_archive_link( 'peptide' ) . '">' . __( 'Peptides' ) . '</a>';
}
Input Validation
All user-facing output must be escaped:
// Good
echo esc_html( get_the_title() );
echo esc_url( home_url( '/' ) );
echo esc_attr( $html_attribute_value );
// Bad
echo get_the_title();
echo home_url( '/' );
Dark Mode Implementation Guide
How Dark Mode Works in Peptide Starter
-
Inline script in
wp_head(functions.php, line 406-422)- Runs immediately before CSS loads
- Prevents white flash by setting
html[data-theme]early - Checks in order: localStorage → system pref → customizer default
-
CSS selectors (style.css, line ~1200 "Dark Mode")
- Light mode: default colors in
:root - Dark mode:
html[data-theme="dark"]selector overrides
- Light mode: default colors in
-
JavaScript listener (theme.js, line 47-63)
- Responds to dark mode toggle button
- Updates localStorage
- Dispatches
themechangeevent to plugins - Updates sun/moon icon visibility
Adding Dark Mode to a New Component
Example: New Alert Style
-
Define light mode in CSS:
.ps-alert-custom { padding: var(--spacing-md); background-color: #E3F2FD; /* Light blue */ color: #0D47A1; /* Dark blue text */ border: 1px solid #90CAF9; } -
Add dark mode override:
html[data-theme="dark"] .ps-alert-custom { background-color: #1A237E; /* Dark blue bg */ color: #E3F2FD; /* Light text */ border-color: #3F51B5; /* Darker border */ } -
Test:
- Load page in light mode → verify colors
- Toggle dark mode → verify override colors are applied
- Open DevTools → verify
html[data-theme="dark"]attribute set - Check localStorage
peptide-starter-themeis updated
Helping Plugins Support Dark Mode
If a plugin doesn't listen to dark mode changes:
-
Ask plugin author to listen to the
themechangeevent:document.addEventListener('themechange', (e) => { const theme = e.detail.theme; // Update plugin styles based on theme }); -
Or use a MutationObserver to watch for
data-themechanges:const observer = new MutationObserver(() => { const theme = document.documentElement.getAttribute('data-theme'); // Update styles }); observer.observe(document.documentElement, { attributes: true }); -
Or read CSS variables in the plugin stylesheet:
.plugin-card { color: var(--text-primary); background-color: var(--bg-secondary); }
CSS Variables for Dark Mode
All available variables (defined in :root):
--text-primary /* Main text (light: #111827, dark: #F9FAFB) */
--text-secondary /* Secondary text (light: #6B7280, dark: #D1D5DB) */
--bg-primary /* Main background (light: #FFFFFF, dark: #111827) */
--bg-secondary /* Secondary bg (light: #F3F4F6, dark: #1F2937) */
--color-primary /* Primary accent (light: #0066CC, dark: #3B82F6) */
--border-color /* Border color (light: #E5E7EB, dark: #374151) */
Testing Checklist
When modifying the theme, verify:
- Light mode — All text readable, colors correct
- Dark mode — Toggle to dark, verify colors swap, no white flash on reload
- Mobile (480px) — Navigation collapses, search/dark mode icons present
- Tablet (768px) — Layout adapts, footer 2-column if needed
- Desktop (1024px+) — Full layout renders correctly
- Plugins active — Shortcodes render, no JS errors in console
- Plugins inactive — Graceful fallbacks work (search form, no broken layout)
- Accessibility — Tab through page, all interactive elements reachable
- Focus states — Every button/link has visible focus outline
- Keyboard navigation — Escape closes overlays, arrow keys in menu if relevant
Common Tasks & Commands
Check JavaScript syntax:
node --check assets/js/theme.js
node --check assets/js/navigation.js
View theme version:
echo PEPTIDE_STARTER_VERSION; // Currently: 1.3.1
Update theme version (when releasing):
- Edit
style.cssline 7:Version: X.Y.Z - Edit
functions.phpline 14:define( 'PEPTIDE_STARTER_VERSION', 'X.Y.Z' )
Deploy to production:
- Push to
mainbranch on GitHub - GitHub Actions runs rsync to Hostinger via SSH
- LiteSpeed cache clears automatically
Debug active plugins:
// In functions.php or template
$active = get_option( 'active_plugins' );
error_log( print_r( $active, true ) );
View Customizer settings:
echo get_theme_mod( 'hero_title' );
echo get_theme_mod( 'dark_mode_default', false );
Trust model for request headers (v1.5.2)
Never trust HTTP_X_FORWARDED_FOR, HTTP_CF_CONNECTING_IP, or any other
client-controlled header without first validating the peer. Always read
the client IP via peptide_starter_get_client_ip() — it handles the
Cloudflare-peer check, the XFF opt-in, and the REMOTE_ADDR fallback.
If a new feature needs to trust a different proxy header (e.g. a new CDN in front of CF):
- Add the upstream proxy's IP ranges to a new file under
inc/. - Extend
peptide_starter_get_client_ip()with a new validated branch guarded by a filter defaulted to off. - Never accept a header whose source can't be validated at the TCP layer.
Refresh the Cloudflare range snapshot in inc/cloudflare-ips.php
quarterly. Out-of-band bumps go through the
peptide_starter_cloudflare_ip_ranges filter.
Security Patterns (v1.5.1)
Rate-limit a handler
Every new public POST handler must be rate-limited.
// After nonce + honeypot checks.
$identifier = peptide_starter_hash_identifier( strtolower( $email ) ); // or 'ip' for IP-only bucket
if ( ! Peptide_Starter_Rate_Limiter::check( 'login', $identifier ) ) {
// Respond with the SAME message the real-validation-failure path uses.
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
}
Peptide_Starter_Rate_Limiter::record( 'login', $identifier ); // on failure
// ...successful operation...
Peptide_Starter_Rate_Limiter::reset( 'login', $identifier ); // on success
Pick a short action slug. Register its limit in inc/config.php under
rate_limits. Never hardcode numbers in the handler.
Honeypot a form
- Render the trap in the form template part:
<?php peptide_starter_render_honeypot( 'myform' ); ?>
- In the handler, right after nonce verification:
if ( peptide_starter_honeypot_triggered( 'ps_hp_myform' ) ) {
// Fake-success response — do NOT return an error. Bots learn from errors.
wp_send_json_success( array( 'message' => __( 'OK', 'peptide-starter' ) ) );
}
Gate a template
At the very top of the template, after the ABSPATH guard, before
get_header():
peptide_starter_require_login();
This redirects unauthenticated users to /auth?redirect_to=current-uri
and unverified users to /profile?verify_required=1. Do not reinvent
this per-template.
CSV-safe export
Route every cell through the helper before fputcsv:
fputcsv( $output, array(
peptide_starter_csv_safe( $email ),
peptide_starter_csv_safe( $date ),
) );
Error unification
Login + registration failures must emit the same message regardless of which validation check failed. Use:
peptide_starter_login_failure_message()— "Invalid email or password."peptide_starter_register_failure_message()— "Unable to create account..."
Branching on failure reason is an enumeration oracle. Don't do it.
Adding a new security threshold
Add a key to the defaults array in inc/config.php and always read it
via peptide_starter_config_get() or the shortcut helpers. No magic
numbers in handler files.
Related Documentation
- ARCHITECTURE.md — System design, data flows, integrations
- CLAUDE.md — Developer context and quick reference
- README.md — User-facing feature documentation
- CHANGELOG.md — Version history