@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-15T23:22:48Z"^^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>Part of:</strong> <a href=\"https://lhero.org/portfolio/lh-vcard/\">LH Vcard</a></li></ul><h2 class=\"wp-block-heading\">Signed download links, not WordPress nonces</h2><p class=\"wp-block-paragraph\">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(&#039;auth&#039;)</code>. It is deliberately <strong>not</strong> a <code>wp_create_nonce()</code> nonce &#8211; a WordPress nonce&#039;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 class=\"wp-block-paragraph\">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&#039;t expired. This mirrors <code>lh-save-down</code>&#039;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 class=\"wp-block-heading\">REST route, not admin-ajax</h2><p class=\"wp-block-paragraph\"><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>&#039;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&#039;s own <code>current_user_can()</code> check, or an MCP ability&#039;s <code>permission_callback</code>) &#8211; not on whoever later opens it.</p><p class=\"wp-block-paragraph\"><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 class=\"wp-block-heading\">2026: retired the admin-ajax path entirely</h2><p class=\"wp-block-paragraph\">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&#039;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 class=\"wp-block-paragraph\"><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>&#039;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 class=\"wp-block-heading\">TTL: 15 minutes → 24 hours</h2><p class=\"wp-block-paragraph\">The signed link&#039;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>&#039;s own default for its (structurally identical) signed export links.</p><h2 class=\"wp-block-heading\">2026: vCard generation moved to sabre/vobject</h2><p class=\"wp-block-paragraph\">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&#039;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&#039;s own 4.0 default) for compatibility with older phone contact-import apps.</p><p class=\"wp-block-paragraph\">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&#039;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 class=\"wp-block-paragraph\">The old library&#039;s photo/URL-fetching helpers (<code>file_get_contents()</code>, PHP&#039;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 class=\"wp-block-paragraph\">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 class=\"wp-block-heading\">2026: split from a god class into one class per concern</h2><p class=\"wp-block-paragraph\">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 class=\"wp-block-list\"><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 class=\"wp-block-paragraph\"><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&#039;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 class=\"wp-block-paragraph\"><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>&#039;s method of the same name, rather than moved outright &#8211; <code>lh-profile-page</code>&#039;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 class=\"wp-block-paragraph\">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 "Part of: LH VcardSigned download links, not WordPress noncesEvery 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(&#039;auth&#039;). It is deliberately not a wp_create_nonce() nonce &#8211; a WordPress nonce&#039;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&#039;t expired. This mirrors lh-save-down&#039;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-ajaxGET /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&#039;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&#039;s own current_user_can() check, or an MCP ability&#039;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 entirelyVersions 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&#039;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&#039;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 → 24 hoursThe signed link&#039;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&#039;s own default for its (structurally identical) signed export links.2026: vCard generation moved to sabre/vobjectThe 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&#039;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&#039;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&#039;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&#039;s photo/URL-fetching helpers (file_get_contents(), PHP&#039;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 concernBefore 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 builderLH_Vcard_Builder &#8211; vCard content assembly and sabre/vobject serialisationLH_Vcard_Rest_Controller &#8211; the download REST routeLH_Vcard_Recently_Updated &#8211; tracking, cached query, dashboard widget, shortcode, activation backfillLH_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&#039;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&#039;s method of the same name, rather than moved outright &#8211; lh-profile-page&#039;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/?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/?taxonomy=author&term=cap-1> rdfs:seeAlso <https://lhero.org/?taxonomy=author&term=cap-1&feed=lhrdf&format=turtle> .
