Skip to content
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,21 @@ Sensitive areas in this codebase where hygiene matters most:
- `src/SiteAccess.php`, `src/Remote.php` — SaaS envelope exchange, auth tokens
- `src/SupportUser.php`, `src/SupportRole.php` — privilege boundary, capability grants
- `src/Form.php` — user-facing rendering, escaping

## Comment Discipline

Default to writing no comments. A well-named identifier and the code that follows it should carry the meaning. Add a comment only when the *why* is non-obvious to a future reader.

**Don't write meta-narrative.** Comments that explain the developer's reasoning — "Defense in depth:", "Belt-and-suspenders:", "Cheaper than the alternative because…", "We do this instead of X because Y" — are narrative *about* the code, not part of it. PR descriptions and commit messages are the right home for that voice. Comments that survive in source become stale signal that rots faster than the code does.

**Don't restate the obvious.** A comment immediately above `update_user_meta(...)` saying "Update the user meta" wastes everyone's time. If the call doesn't read clearly, fix the names — don't paper over with prose.

**Don't reference the task, PR, or audit that prompted the code.** No "added for issue #66", "per review feedback", "this was the fix in PR #142". Those handles rot the moment the PR is closed. The thing the comment cared about is in `git blame` and in the PR body — leave it there.

**Don't narrate version-target trade-offs at the call site.** A comment like "we duplicate the cleanup here because the SDK targets PHP 5.3 (no `finally` until 5.5)" is meta narrative about *why the file looks this way*. A reader can see it looks that way; the *why* belongs in this file, not at every call site.

**Do keep comments** that describe a hidden constraint, a subtle invariant, a workaround for a specific bug, or a surprising behavior — things a reader would otherwise have to discover the hard way. These earn their place.

### PHP version target

The SDK runtime supports **PHP 5.3+** (per `composer.json` and `.phpcs.xml.dist`'s `testVersion`). New code must run on 5.3 — no `\Throwable`, no `finally`, no `static function () {}`, no `??` null coalesce, no return-type hints, no arrow functions. Build/test environments are PHP 7.4+ (PHPStan `phpVersion: 70400`, PHPUnit on 8.2), but anything the SDK actually ships has to clear the 5.3 bar.
8 changes: 4 additions & 4 deletions src/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ public function user_row_action_revoke( $actions, $user_object ) {
}

return array(
'revoke' => "<a class='trustedlogin tl-revoke submitdelete' href='" . esc_url( $revoke_url ) . "'>" . esc_html__( 'Revoke Access', 'trustedlogin' ) . '</a>',
'revoke' => "<a class='trustedlogin tl-revoke submitdelete' href='" . esc_url( $revoke_url ) . "'>" . esc_html( Strings::get( Strings::REVOKE_ACCESS, __( 'Revoke Access', 'trustedlogin' ) ) ) . '</a>',
);
}

Expand Down Expand Up @@ -267,12 +267,12 @@ public function admin_bar_add_toolbar_items( $admin_bar ) {
$admin_bar->add_menu(
array(
'id' => 'tl-' . $this->config->ns() . '-revoke',
'title' => $icon . esc_html__( 'Revoke Access', 'trustedlogin' ),
'title' => $icon . esc_html( Strings::get( Strings::REVOKE_ACCESS, __( 'Revoke Access', 'trustedlogin' ) ) ),
'href' => $this->support_user->get_revoke_url( 'all' ),
'parent' => 'top-secondary',
'meta' => array(
'class' => 'tl-destroy-session',
'title' => esc_html__( 'You are logged in as a support user. Click to permanently revoke access.', 'trustedlogin' ),
'title' => esc_html( Strings::get( Strings::YOU_ARE_LOGGED_IN_AS_A, __( 'You are logged in as a support user. Click to permanently revoke access.', 'trustedlogin' ) ) ),
),
)
);
Expand Down Expand Up @@ -305,7 +305,7 @@ public function admin_menu_auth_link_page() {

$menu_slug = apply_filters( 'trustedlogin/' . $this->config->ns() . '/admin/menu/menu_slug', 'grant-' . $ns . '-access' );

$menu_title = $this->config->get_setting( 'menu/title', esc_html__( 'Grant Support Access', 'trustedlogin' ) );
$menu_title = $this->config->get_setting( 'menu/title', esc_html( Strings::get( Strings::GRANT_SUPPORT_ACCESS, __( 'Grant Support Access', 'trustedlogin' ) ) ) );

$menu_position = $this->config->get_setting( 'menu/position', null );
$menu_position = is_null( $menu_position ) ? null : (float) $menu_position;
Expand Down
4 changes: 2 additions & 2 deletions src/Ajax.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,13 @@ public function ajax_generate_support() {
// `wp_ajax_…` only (no `wp_ajax_nopriv_…`), so `get_current_user_id()`
// is guaranteed non-zero here even though that isn't obvious in isolation.
if ( ! check_ajax_referer( 'tl_nonce-' . get_current_user_id(), '_nonce', false ) ) {
wp_send_json_error( array( 'message' => esc_html__( 'Verification issue: Request could not be verified. Please reload the page.', 'trustedlogin' ) ) );
wp_send_json_error( array( 'message' => esc_html( Strings::get( Strings::VERIFICATION_ISSUE_REQUEST_COULD_NOT_BE, __( 'Verification issue: Request could not be verified. Please reload the page.', 'trustedlogin' ) ) ) ) );
}

if ( ! current_user_can( 'create_users' ) ) {
$this->logging->log( 'Current user does not have `create_users` capability when trying to grant access.', __METHOD__, 'error' );

wp_send_json_error( array( 'message' => esc_html__( 'You do not have the ability to create users.', 'trustedlogin' ) ) );
wp_send_json_error( array( 'message' => esc_html( Strings::get( Strings::YOU_DO_NOT_HAVE_THE_ABILITY, __( 'You do not have the ability to create users.', 'trustedlogin' ) ) ) ) );
}

// Reuse the injected Client if available (hooks already wired). Fall
Expand Down
8 changes: 5 additions & 3 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ public function __construct( Config $config, $init = true ) {

$this->config = $config;

Strings::init( $config );

$this->logging = new Logging( $config );

$this->cron = new Cron( $this->config, $this->logging );
Expand Down Expand Up @@ -263,7 +265,7 @@ public function grant_access( $include_debug_data = false, $ticket_data = null )
// user — it can never be used to log in because no envelope
// reached the vendor dashboard. Fail-fast keeps state clean.
if ( ! $this->config->meets_ssl_requirement() ) {
return new WP_Error( 'fails_ssl_requirement', esc_html__( 'Support access requires a secure (HTTPS) connection. Please enable HTTPS on this site and try again.', 'trustedlogin' ), array( 'error_code' => 426 ) );
return new WP_Error( 'fails_ssl_requirement', esc_html( Strings::get( Strings::SUPPORT_ACCESS_REQUIRES_A_SECURE_HTTPS, __( 'Support access requires a secure (HTTPS) connection. Please enable HTTPS on this site and try again.', 'trustedlogin' ) ) ), array( 'error_code' => 426 ) );
}

timer_start();
Expand Down Expand Up @@ -444,7 +446,7 @@ private function extend_access( $user_id ) {
// in via the vendor dashboard still reflects the old expiration.
// Fail fast so the customer is asked to enable HTTPS and retry.
if ( ! $this->config->meets_ssl_requirement() ) {
return new WP_Error( 'fails_ssl_requirement', esc_html__( 'Support access requires a secure (HTTPS) connection. Please enable HTTPS on this site and try again.', 'trustedlogin' ), array( 'error_code' => 426 ) );
return new WP_Error( 'fails_ssl_requirement', esc_html( Strings::get( Strings::SUPPORT_ACCESS_REQUIRES_A_SECURE_HTTPS, __( 'Support access requires a secure (HTTPS) connection. Please enable HTTPS on this site and try again.', 'trustedlogin' ) ) ), array( 'error_code' => 426 ) );
}

timer_start();
Expand Down Expand Up @@ -645,7 +647,7 @@ public function revoke_access( $identifier = '' ) {
if ( ! empty( $should_be_deleted ) ) {
$this->logging->log( 'User #' . $should_be_deleted->ID . ' was not removed', __METHOD__, 'error' );

return new WP_Error( 'support_user_not_deleted', esc_html__( 'The support user was not deleted.', 'trustedlogin' ) );
return new WP_Error( 'support_user_not_deleted', esc_html( Strings::get( Strings::THE_SUPPORT_USER_WAS_NOT_DELETED, __( 'The support user was not deleted.', 'trustedlogin' ) ) ) );
}

/**
Expand Down
124 changes: 124 additions & 0 deletions src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,13 @@ public function validate() {
}
}

// Walk the optional `strings` array and discard malformed
// overrides individually. Bad entries log a warning and fall
// back to the SDK default; well-formed entries remain. We
// don't push these to $errors because a typo in one string
// shouldn't refuse to instantiate the SDK.
$this->validate_strings();

if ( $errors ) {
$error_text = array();
foreach ( $errors as $error ) {
Expand All @@ -397,6 +404,123 @@ public function validate() {
return true;
}

/**
* Validate the optional `strings` config setting in place.
*
* Drops the whole entry if not an array; otherwise prunes
* malformed individual overrides while keeping valid ones.
* Closures pass through untouched; strings are checked against
* the placeholder count declared in {@see Strings::registry()};
* empty strings are accepted as "render nothing".
*
* @since 1.11.0
*
* @return void
*/
private function validate_strings() {
if ( ! isset( $this->settings['strings'] ) ) {
return;
}

if ( ! is_array( $this->settings['strings'] ) ) {
unset( $this->settings['strings'] );
return;
}

$registry = Strings::registry();
$validated = array();

foreach ( $this->settings['strings'] as $key => $override ) {
if ( ! is_string( $key ) || ! isset( $registry[ $key ] ) ) {
continue;
}

$placeholders = isset( $registry[ $key ]['placeholders'] )
? (int) $registry[ $key ]['placeholders']
: 0;

if ( is_callable( $override ) ) {
$validated[ $key ] = $override;
continue;
}

if ( '' === $override ) {
$validated[ $key ] = '';
continue;
}

if ( ! is_string( $override ) ) {
continue;
}

if ( ! self::placeholders_safe( $override, $placeholders ) ) {
continue;
}

$validated[ $key ] = $override;
}

$this->settings['strings'] = $validated;

// Drop the cached pre-validation read; subsequent get_setting()
// calls must hit the pruned array.
unset( $this->settings_cache['strings'] );
}

/**
* Does $template survive `vsprintf` against $arg_count placeholder args?
*
* Sentinel-based behavioural test: each arg slot is given a unique
* marker and the rendered output must contain every marker. Catches
* too-few-args, missing slots, and wrong-conversion-type in one shot
* without re-implementing the sprintf grammar.
*
* @since 1.11.0
*
* @param string $template Override candidate to test (string from integrator config).
* @param int $arg_count Number of positional args the SDK default requires.
*
* @return bool True if the template renders cleanly with $arg_count args.
*/
private static function placeholders_safe( $template, $arg_count ) {
if ( $arg_count <= 0 ) {
$stripped = str_replace( '%%', '', (string) $template );
return ! (bool) preg_match( '/%[+\-0-9.\'$]*[a-zA-Z]/', $stripped );
}

$sentinels = array();
for ( $i = 0; $i < $arg_count; $i++ ) {
$sentinels[] = '__TLPLACEHOLDER' . $i . '__';
}

// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
set_error_handler(
function () {
return true;
},
E_WARNING
);

$result = false;
try {
$result = vsprintf( (string) $template, $sentinels );
} catch ( \Exception $_ ) {
$result = false;
}
restore_error_handler();

if ( false === $result || ! is_string( $result ) ) {
return false;
}

foreach ( $sentinels as $sentinel ) {
if ( false === strpos( $result, $sentinel ) ) {
return false;
}
}
return true;
}

/**
* Returns a timestamp that is the current time + decay time setting
*
Expand Down
4 changes: 2 additions & 2 deletions src/Encryption.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ public function get_vendor_public_key() {

return new WP_Error(
'invalid_public_key_shape',
esc_html__( 'Support access could not be set up. The plugin\'s support team\'s encryption key has an unexpected format — please contact them and let them know.', 'trustedlogin' )
esc_html( Strings::get( Strings::SUPPORT_ACCESS_COULD_NOT_BE_SET, __( 'Support access could not be set up. The plugin\'s support team\'s encryption key has an unexpected format — please contact them and let them know.', 'trustedlogin' ) ) )
);
}

Expand All @@ -283,7 +283,7 @@ public function get_vendor_public_key() {

return new WP_Error(
'public_key_fingerprint_mismatch',
esc_html__( 'Support access could not be set up. The plugin\'s support team\'s encryption key didn\'t match the configured fingerprint — please contact them.', 'trustedlogin' )
esc_html( Strings::get( Strings::SUPPORT_ACCESS_COULD_NOT_BE_SET_799354, __( 'Support access could not be set up. The plugin\'s support team\'s encryption key didn\'t match the configured fingerprint — please contact them.', 'trustedlogin' ) ) )
);
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/Endpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,8 @@ private function fail_login( $error_code, $detailed_reason, $site_identifier_has
* with their own error chrome.
*/
private function render_standalone_failure_page() {
$heading = __( 'Support login could not complete', 'trustedlogin' );
$body = __( 'Return to your support tool to try again.', 'trustedlogin' );
$heading = Strings::get( Strings::SUPPORT_LOGIN_COULD_NOT_COMPLETE, __( 'Support login could not complete', 'trustedlogin' ) );
$body = Strings::get( Strings::RETURN_TO_YOUR_SUPPORT_TOOL_TO, __( 'Return to your support tool to try again.', 'trustedlogin' ) );

wp_die(
'<p>' . esc_html( $body ) . '</p>',
Expand Down
Loading
Loading