@prefix sioc: <http://rdfs.org/sioc/ns#> .
@prefix dc: <http://purl.org/dc/elements/1.1/> .
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix content: <http://purl.org/rss/1.0/modules/content/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

<https://lhero.org/?post_type=lh-portfolio&#038;p=147189>
  a sioc:Post ;
  dc:title "Architectural Notes" ;
  dcterms:identifier 147189 ;
  dc:modified "2026-09-11T16:03:41Z"^^xsd:dateTime ;
  dc:created "2026-09-09T16:13:16Z"^^xsd:dateTime ;
  sioc:link <https://lhero.org/portfolio/lh-vcard/architectural-notes/> ;
  sioc:has_creator <https://lhero.org/author/1/#account> ;
  sioc:has_container <https://lhero.org/#posts> ;
  content:encoded """<ul class="lh_portfolio-meta"><li><strong>Type:</strong> Doc-section</li><li><strong>Part of:</strong> <a href="https://lhero.org/portfolio/lh-vcard/">LH Vcard</a></li></ul><h2>Signed download links, not WordPress nonces</h2>
<p>Every vCard download link this plugin generates (<code>LH_Vcard_plugin::return_signed_vcard_download_url()</code>) is a self-contained, expiring token: <code>{expiry_timestamp}.{hmac_hash}</code>, where the hash covers the sorted user ID list and the expiry, keyed on <code>wp_salt('auth')</code>. It is deliberately <strong>not</strong> a <code>wp_create_nonce()</code> nonce &#8211; a WordPress nonce&#8217;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.</p>
<p>The token depends only on <code>(user_ids, expiry)</code> plus the server-side secret &#8211; identical validity for any requester, for as long as it hasn&#8217;t expired. This mirrors <code>lh-save-down</code>&#8216;s <code>generate_export_token()</code>/<code>verify_export_token()</code> pattern for post exports exactly, scoped to a set of users instead of a single post.</p>
<h2>REST route, not admin-ajax</h2>
<p><code>GET /wp-json/lh-vcard/v1/download</code> verifies the token and streams the vCard directly &#8211; no REST response envelope, the same way <code>lh-save-down</code>&#8216;s feed callbacks stream PDFs/epubs directly. <code>permission_callback</code> is deliberately <code>__return_true</code>: 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 <em>generate</em> a link (an admin screen&#8217;s own <code>current_user_can()</code> check, or an MCP ability&#8217;s <code>permission_callback</code>) &#8211; not on whoever later opens it.</p>
<p><code>is_user_member_of_blog()</code> 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.</p>
<h2>2026: retired the admin-ajax path entirely</h2>
<p>Versions up to 1.01 served vCards via <code>admin-ajax.php?action=lh_vcard-do_vcard</code>, gated by <code>is_user_logged_in()</code> + a capability check (a nonce was generated and attached to the URL but never actually verified server-side &#8211; it provided no real security). 1.02 added the signed REST route as a second, narrower mechanism just for one MCP ability&#8217;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 (<code>return_vcard_link()</code>, <code>maybe_return_object()</code>, <code>handle_singular_user_id()</code>, <code>handle_multiple_user_id()</code>) entirely. The plugin now has exactly one vCard download mechanism.</p>
<p><strong>Residual risk, flagged deliberately:</strong> 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 &#8211; <code>lh-profile-page</code>&#8216;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 <code>...admin-ajax.php?action=lh_vcard-do_vcard...</code> URL directly and it silently stops working, that removal is the cause.</p>
<h2>TTL: 15 minutes &#8594; 24 hours</h2>
<p>The signed link&#8217;s expiry (<code>lh_vcard_signed_link_ttl</code> 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 &#8211; including the dashboard widget and group reports, which can sit open or unvisited for hours before a link is actually clicked &#8211; the default was raised to <code>DAY_IN_SECONDS</code>, matching <code>lh-save-down</code>&#8216;s own default for its (structurally identical) signed export links.</p>
<h2>2026: vCard generation moved to sabre/vobject</h2>
<p>The vCard text itself was originally built by <code>includes/wp-gvc-cf-vcard.class.php</code>, a bundled library with no author, license, or version header &#8211; not something that could be tracked via Composer, since it wasn&#8217;t a real published package. 1.04 replaced it with <a href="https://sabre.io/vobject/">sabre/vobject</a> (Packagist, actively maintained), pulled in via a plugin-level <code>composer.json</code>. Output is still forced to vCard <strong>3.0</strong> (not sabre/vobject&#8217;s own 4.0 default) for compatibility with older phone contact-import apps.</p>
<p>Two real bugs in the old library were fixed as a byproduct of the switch, not by hand-patching the old code: its <code>ADR</code> line had a stray extra field (8 components instead of RFC 6350&#8217;s 7), and it never line-folded long <code>PHOTO</code> data at the RFC-required 75-character continuation width. Verified directly against a live-generated vCard post-migration.</p>
<p>The old library&#8217;s photo/URL-fetching helpers (<code>file_get_contents()</code>, PHP&#8217;s own <code>parse_url()</code>) were also replaced with <code>wp_remote_get()</code> and <code>wp_parse_url()</code> respectively &#8211; the WordPress-native equivalents, fixing two more plugin-check flags along the way. <code>includes/wp-gvc-cf-vcard.class.php</code> itself was deleted once nothing referenced it any longer.</p>
<p>The data contract feeding vCard generation &#8211; the <code>$add</code> array shape from <code>add_to_vcard_data()</code> and the <code>lh_vcard_add_data</code> filter other plugins (e.g. <code>lh-profile-page</code>) hook into &#8211; is unchanged; only the internal rendering engine moved.</p>
<h2>2026: split from a god class into one class per concern</h2>
<p>Before 1.06, <code>LH_Vcard_plugin</code> owned every concern directly &#8211; signed tokens, REST handling, vCard generation, the recently-updated feature, and every third-party UI integration &#8211; roughly 1,000 lines in one class. 1.06 split it into:</p>
<ul>
<li><code>LH_Vcard_Signed_Link</code> &#8211; token generation/verification/TTL, URL builder</li>
<li><code>LH_Vcard_Builder</code> &#8211; vCard content assembly and sabre/vobject serialisation</li>
<li><code>LH_Vcard_Rest_Controller</code> &#8211; the download REST route</li>
<li><code>LH_Vcard_Recently_Updated</code> &#8211; tracking, cached query, dashboard widget, shortcode, activation backfill</li>
<li><code>LH_Vcard_Integrations</code> &#8211; the six unrelated third-party UI touchpoints (user row actions, BuddyPress member/group links, LH Group Reports, LH Check Ins, bbPress reply links)</li>
</ul>
<p><code>LH_Vcard_plugin</code> itself shrank to a thin bootstrap: identity methods, hook wiring, activation/deactivation, cron setup. Matches the precedent already established in <code>lh-profile-page</code>, which underwent the same one-class-per-concern split (see that plugin&#8217;s field class docblocks, most concretely its 2.03 phone-field fatal, which traced directly back to an overloaded class doing too much).</p>
<p><code>LH_Vcard_plugin::return_signed_vcard_download_url()</code> was kept in place as a one-line proxy to <code>LH_Vcard_Signed_Link</code>&#8216;s method of the same name, rather than moved outright &#8211; <code>lh-profile-page</code>&#8216;s <code>search-users</code> MCP ability calls that exact class/method directly, a confirmed, current, load-bearing external caller, not a hypothetical one.</p>
<p>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 <code>sabre/vobject</code> regenerates randomly on every call regardless of refactor.</p>
"""^^rdf:XMLLiteral ;
  sioc:content """Type: Doc-sectionPart of: LH VcardSigned download links, not WordPress nonces
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 &#8211; a WordPress nonce&#8217;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 &#8211; identical validity for any requester, for as long as it hasn&#8217;t expired. This mirrors lh-save-down&#8216;s generate_export_token()/verify_export_token() pattern for post exports exactly, scoped to a set of users instead of a single post.
REST route, not admin-ajax
GET /wp-json/lh-vcard/v1/download verifies the token and streams the vCard directly &#8211; no REST response envelope, the same way lh-save-down&#8216;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&#8217;s own current_user_can() check, or an MCP ability&#8217;s permission_callback) &#8211; 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.
2026: retired the admin-ajax path entirely
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 &#8211; it provided no real security). 1.02 added the signed REST route as a second, narrower mechanism just for one MCP ability&#8217;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 &#8211; lh-profile-page&#8216;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.
TTL: 15 minutes &#8594; 24 hours
The signed link&#8217;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 &#8211; including the dashboard widget and group reports, which can sit open or unvisited for hours before a link is actually clicked &#8211; the default was raised to DAY_IN_SECONDS, matching lh-save-down&#8216;s own default for its (structurally identical) signed export links.
2026: vCard generation moved to sabre/vobject
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 &#8211; not something that could be tracked via Composer, since it wasn&#8217;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&#8217;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&#8217;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&#8217;s photo/URL-fetching helpers (file_get_contents(), PHP&#8217;s own parse_url()) were also replaced with wp_remote_get() and wp_parse_url() respectively &#8211; 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 &#8211; 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 &#8211; is unchanged; only the internal rendering engine moved.
2026: split from a god class into one class per concern
Before 1.06, LH_Vcard_plugin owned every concern directly &#8211; signed tokens, REST handling, vCard generation, the recently-updated feature, and every third-party UI integration &#8211; roughly 1,000 lines in one class. 1.06 split it into:

LH_Vcard_Signed_Link &#8211; token generation/verification/TTL, URL builder
LH_Vcard_Builder &#8211; vCard content assembly and sabre/vobject serialisation
LH_Vcard_Rest_Controller &#8211; the download REST route
LH_Vcard_Recently_Updated &#8211; tracking, cached query, dashboard widget, shortcode, activation backfill
LH_Vcard_Integrations &#8211; 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&#8217;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&#8216;s method of the same name, rather than moved outright &#8211; lh-profile-page&#8216;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.
""" ;
  sioc:topic <https://lhero.org/lh_portfolio-type/doc-section/>, <https://lhero.org/?taxonomy=author&term=cap-1> .

<https://lhero.org/author/1/#account> rdfs:seeAlso <https://lhero.org/author/1/?feed=lhrdf&format=turtle> .
<https://lhero.org/lh_portfolio-type/doc-section/> rdfs:seeAlso <https://lhero.org/lh_portfolio-type/doc-section/?feed=lhrdf&format=turtle> .
<https://lhero.org/?taxonomy=author&term=cap-1> rdfs:seeAlso <https://lhero.org/?taxonomy=author&term=cap-1&feed=lhrdf&format=turtle> .
