Every vCard download link this plugin generates (LH_Vcard_plugin::return_signed_vcard_download_url()) is a self-contained, expiring token: {expiry_timestamp}.{hmac_hash}, where the hash covers the sorted user ID list and the expiry, keyed on wp_salt('auth'). It is deliberately not a wp_create_nonce() nonce – a WordPress nonce’s validity is tied to whichever user is currently logged in at both generation and verification time. That works fine for a link a person generates and clicks themselves in the same browser session, but breaks for a link generated by one identity (e.g. an MCP ability running as its own service account) and opened by a different person in a different, unrelated session.
The token depends only on (user_ids, expiry) plus the server-side secret – identical validity for any requester, for as long as it hasn’t expired. This mirrors lh-save-down‘s generate_export_token()/verify_export_token() pattern for post exports exactly, scoped to a set of users instead of a single post.
GET /wp-json/lh-vcard/v1/download verifies the token and streams the vCard directly – no REST response envelope, the same way lh-save-down‘s feed callbacks stream PDFs/epubs directly. permission_callback is deliberately __return_true: the route is reachable without being logged in at all, because the token itself is the authorization. Any capability check belongs upstream, on whoever is allowed to generate a link (an admin screen’s own current_user_can() check, or an MCP ability’s permission_callback) – not on whoever later opens it.
is_user_member_of_blog() is still checked at redemption time, but as a data-validity check (only ever export vCards for genuine members of the current site), not an attempt to authorize the requester.
Versions up to 1.01 served vCards via admin-ajax.php?action=lh_vcard-do_vcard, gated by is_user_logged_in() + a capability check (a nonce was generated and attached to the URL but never actually verified server-side – it provided no real security). 1.02 added the signed REST route as a second, narrower mechanism just for one MCP ability’s use. 1.03 switched every remaining caller over (user row actions, BuddyPress profile/group links, group reports, the dashboard widget, bbPress reply links) and removed the admin-ajax handler, its hook registration, and the now-dead helper methods (return_vcard_link(), maybe_return_object(), handle_singular_user_id(), handle_multiple_user_id()) entirely. The plugin now has exactly one vCard download mechanism.
Residual risk, flagged deliberately: at removal time there was no tool available to search across every other plugin on the network for a direct call to one of the removed methods. This codebase has a documented precedent of exactly that kind of assumption failing before – lh-profile-page‘s 2.03 phone-field fatal happened because a method assumed to be internal-only turned out to have an external caller. If something on the site starts building a ...admin-ajax.php?action=lh_vcard-do_vcard... URL directly and it silently stops working, that removal is the cause.
The signed link’s expiry (lh_vcard_signed_link_ttl filter) started at 15 minutes when this was a narrow addition serving only a single, freshly-generated MCP-ability link. Once every UI surface switched over in 1.03 – including the dashboard widget and group reports, which can sit open or unvisited for hours before a link is actually clicked – the default was raised to DAY_IN_SECONDS, matching lh-save-down‘s own default for its (structurally identical) signed export links.
The vCard text itself was originally built by includes/wp-gvc-cf-vcard.class.php, a bundled library with no author, license, or version header – not something that could be tracked via Composer, since it wasn’t a real published package. 1.04 replaced it with sabre/vobject (Packagist, actively maintained), pulled in via a plugin-level composer.json. Output is still forced to vCard 3.0 (not sabre/vobject’s own 4.0 default) for compatibility with older phone contact-import apps.
Two real bugs in the old library were fixed as a byproduct of the switch, not by hand-patching the old code: its ADR line had a stray extra field (8 components instead of RFC 6350’s 7), and it never line-folded long PHOTO data at the RFC-required 75-character continuation width. Verified directly against a live-generated vCard post-migration.
The old library’s photo/URL-fetching helpers (file_get_contents(), PHP’s own parse_url()) were also replaced with wp_remote_get() and wp_parse_url() respectively – the WordPress-native equivalents, fixing two more plugin-check flags along the way. includes/wp-gvc-cf-vcard.class.php itself was deleted once nothing referenced it any longer.
The data contract feeding vCard generation – the $add array shape from add_to_vcard_data() and the lh_vcard_add_data filter other plugins (e.g. lh-profile-page) hook into – is unchanged; only the internal rendering engine moved.
Before 1.06, LH_Vcard_plugin owned every concern directly – signed tokens, REST handling, vCard generation, the recently-updated feature, and every third-party UI integration – roughly 1,000 lines in one class. 1.06 split it into:
LH_Vcard_Signed_Link – token generation/verification/TTL, URL builderLH_Vcard_Builder – vCard content assembly and sabre/vobject serialisationLH_Vcard_Rest_Controller – the download REST routeLH_Vcard_Recently_Updated – tracking, cached query, dashboard widget, shortcode, activation backfillLH_Vcard_Integrations – the six unrelated third-party UI touchpoints (user row actions, BuddyPress member/group links, LH Group Reports, LH Check Ins, bbPress reply links)LH_Vcard_plugin itself shrank to a thin bootstrap: identity methods, hook wiring, activation/deactivation, cron setup. Matches the precedent already established in lh-profile-page, which underwent the same one-class-per-concern split (see that plugin’s field class docblocks, most concretely its 2.03 phone-field fatal, which traced directly back to an overloaded class doing too much).
LH_Vcard_plugin::return_signed_vcard_download_url() was kept in place as a one-line proxy to LH_Vcard_Signed_Link‘s method of the same name, rather than moved outright – lh-profile-page‘s search-users MCP ability calls that exact class/method directly, a confirmed, current, load-bearing external caller, not a hypothetical one.
Verified behaviourally identical: a vCard generated post-refactor was byte-for-byte identical to one generated immediately before it (same FN/N/EMAIL/TEL/URL/PHOTO, including the full base64 photo data), aside from the UID field, which sabre/vobject regenerates randomly on every call regardless of refactor.