Parent: gamification epic #796 (fits E-P5 "try this next" discovery / status surface) · Companion to public profile (#87/#88)
Add-to-LinkedIn: share QtMesh Creator status & project quality as a LinkedIn certification
What & why
Let users add their QtMesh standing to their LinkedIn profile via LinkedIn's "Add to profile" certification link — the same open mechanism Duolingo, freeCodeCamp, and Credly issuers use. Every shared credential links back to qtmesh.dev, so it doubles as an organic growth loop for the public profile (#87) and the gamification engagement goals (#796).
Scope boundary (important): this is the snapshot certification link, NOT the deep LinkedIn↔Duolingo live-score partnership (that needs a negotiated LinkedIn data-partner deal — out of reach for us). The link pre-fills LinkedIn's "Add licenses & certifications" form; the user clicks Save; a static entry appears on their profile pointing back to their QtMesh page as the verification URL. No LinkedIn OAuth, no API approval, no backend service, no data flows back to us — it is a pure client-side URL. This is deliberately not a "connected app" in the OAuth/MCP sense.
The mechanism (LinkedIn add-to-profile URL)
https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME
&name=<credential name>
&organizationName=QtMesh Cloud
&issueYear=<YYYY>&issueMonth=<M>
&certId=<stable id>
&certUrl=<public verification URL back to qtmesh.dev>
All params URL-encoded. startTask=CERTIFICATION_NAME opens the certifications form. certUrl is the verification link (must resolve to a public page that visibly shows the credential belongs to that user). No expirationYear (QtMesh status doesn't expire as a credential).
Two surfaces
Both server-rendered in src/index.ts (the Worker serves these pages — no frontend build change):
1. Public profile page — "QtMesh Creator" credential
- Where:
renderProfilePage() (src/index.ts:7859), served at /u/:userSlug (:7471). Add the button in the profile header (.who block, near the /u/<slug> handle) or under "Shareable badges".
- Credential fields:
name = QtMesh Creator — Level <stats.level> (matches the page's own "QtMesh Creator" eyebrow + level badge). If level is 0/absent, fall back to QtMesh Creator.
organizationName = QtMesh Cloud
certId = the user slug (stable, unique, already public)
certUrl = <origin>/u/<slug>
issueYear/issueMonth = must be passed in, not computed — Date is fine in the Worker runtime here (unlike the workflow sandbox), but prefer the most recent achievement's earnedAt, else the account's first-seen date, so the "issued" date is meaningful and stable across renders rather than "now" every time. If neither is available, omit issueYear/Month (LinkedIn allows it).
- Available data in
renderProfilePage: user.slug, user.name, stats.level, stats.best_score, achievements[] (each has earnedAt, title, tier), origin. (Verified against the current renderer.)
2. Public project report page — "QtMesh Verified" credential (only when gate passes)
- Where: the public report route
/:userSlug/:projectSlug (src/index.ts:7483), which already gates on public_report_enabled === 1.
- Only render the button when the latest scan's gate status is
pass — a failing gate is not a credential. Pull gate status from the same project/scan data the page already loads.
- Credential fields:
name = QtMesh Verified — <projectDisplay> (or include the score: QtMesh Verified — <projectDisplay> (Score <n>))
certId = <ownerSlug>/<projectSlug>
certUrl = the public report URL (fullPageUrl, already computed at :7501)
- If the gate is not passing, render nothing (or a disabled/greyed hint) — never a "verified" cert for a failing project.
Implementation notes
- New helper
linkedInAddCertUrl(opts: { name; organizationName; certId; certUrl; issueYear?; issueMonth? }): string — builds the URL with encodeURIComponent on every value (helpers already used throughout, e.g. :976). Pure function, unit-testable.
- New helper
renderLinkedInButton(url: string): string — returns the anchor markup. Style it to match the existing profile CSS (reuse .stat/badge visual language; LinkedIn brand blue #0A66C2 for the button is acceptable and expected). target="_blank" rel="noopener". Include the LinkedIn "in" glyph inline (SVG, no external asset — the page already inlines all assets/CSS).
- No new routes, no DB change, no auth. Everything derives from data already loaded for these two pages.
- Privacy: only render on pages that are already public (profile is public only when
profilePublic pref is on — the route already 404s otherwise; project button only when public_report_enabled). The button exposes nothing not already on the public page.
- Analytics (optional): a lightweight
?utm_source=linkedin_cert on the certUrl so inbound clicks from LinkedIn are attributable, and/or a Sentry/gamification breadcrumb when the button is present. Keep it optional — don't block the feature on analytics.
Tests
scripts/test-gamification.mjs (pure logic) or a small new test: linkedInAddCertUrl produces a correctly-encoded URL with all params; special chars in name/slug are escaped; omitting issueYear/Month is valid.
scripts/test-gamification-api.mjs (already renders the profile HTML at :98): assert the rendered profile page contains a linkedin.com/profile/add link with the right name/certUrl for a public profile, and that a private profile (404) never emits one.
- Project report: assert the button appears only when gate=pass; absent when gate=fail or report not public.
Acceptance criteria
Non-goals
- The live auto-updating LinkedIn↔Duolingo-style score sync (needs a LinkedIn partnership).
- Any LinkedIn OAuth / "sign in with LinkedIn" / reading LinkedIn data.
- Sharing to other networks (X/Facebook "share" buttons) — separate follow-up if wanted.
References
src/index.ts: renderProfilePage (:7859), profile route (:7471), public report route (:7483, gate + fullPageUrl at :7500-7501), escapeXml (:1654), URL-encode precedent (:976), badge routes (:7229+).
scripts/test-gamification-api.mjs (:88-104 profile render + privacy tests).
- LinkedIn add-to-profile: https://addtoprofile.linkedin.com/ (developer doc for the
profile/add?startTask=CERTIFICATION_NAME URL params).
Parent: gamification epic #796 (fits E-P5 "try this next" discovery / status surface) · Companion to public profile (#87/#88)
Add-to-LinkedIn: share QtMesh Creator status & project quality as a LinkedIn certification
What & why
Let users add their QtMesh standing to their LinkedIn profile via LinkedIn's "Add to profile" certification link — the same open mechanism Duolingo, freeCodeCamp, and Credly issuers use. Every shared credential links back to
qtmesh.dev, so it doubles as an organic growth loop for the public profile (#87) and the gamification engagement goals (#796).Scope boundary (important): this is the snapshot certification link, NOT the deep LinkedIn↔Duolingo live-score partnership (that needs a negotiated LinkedIn data-partner deal — out of reach for us). The link pre-fills LinkedIn's "Add licenses & certifications" form; the user clicks Save; a static entry appears on their profile pointing back to their QtMesh page as the verification URL. No LinkedIn OAuth, no API approval, no backend service, no data flows back to us — it is a pure client-side URL. This is deliberately not a "connected app" in the OAuth/MCP sense.
The mechanism (LinkedIn add-to-profile URL)
All params URL-encoded.
startTask=CERTIFICATION_NAMEopens the certifications form.certUrlis the verification link (must resolve to a public page that visibly shows the credential belongs to that user). NoexpirationYear(QtMesh status doesn't expire as a credential).Two surfaces
Both server-rendered in
src/index.ts(the Worker serves these pages — no frontend build change):1. Public profile page — "QtMesh Creator" credential
renderProfilePage()(src/index.ts:7859), served at/u/:userSlug(:7471). Add the button in the profile header (.whoblock, near the/u/<slug>handle) or under "Shareable badges".name=QtMesh Creator — Level <stats.level>(matches the page's own "QtMesh Creator" eyebrow + level badge). If level is 0/absent, fall back toQtMesh Creator.organizationName=QtMesh CloudcertId= the user slug (stable, unique, already public)certUrl=<origin>/u/<slug>issueYear/issueMonth= must be passed in, not computed —Dateis fine in the Worker runtime here (unlike the workflow sandbox), but prefer the most recent achievement'searnedAt, else the account's first-seen date, so the "issued" date is meaningful and stable across renders rather than "now" every time. If neither is available, omit issueYear/Month (LinkedIn allows it).renderProfilePage:user.slug,user.name,stats.level,stats.best_score,achievements[](each hasearnedAt,title,tier),origin. (Verified against the current renderer.)2. Public project report page — "QtMesh Verified" credential (only when gate passes)
/:userSlug/:projectSlug(src/index.ts:7483), which already gates onpublic_report_enabled === 1.pass— a failing gate is not a credential. Pull gate status from the same project/scan data the page already loads.name=QtMesh Verified — <projectDisplay>(or include the score:QtMesh Verified — <projectDisplay> (Score <n>))certId=<ownerSlug>/<projectSlug>certUrl= the public report URL (fullPageUrl, already computed at:7501)Implementation notes
linkedInAddCertUrl(opts: { name; organizationName; certId; certUrl; issueYear?; issueMonth? }): string— builds the URL withencodeURIComponenton every value (helpers already used throughout, e.g.:976). Pure function, unit-testable.renderLinkedInButton(url: string): string— returns the anchor markup. Style it to match the existing profile CSS (reuse.stat/badge visual language; LinkedIn brand blue#0A66C2for the button is acceptable and expected).target="_blank" rel="noopener". Include the LinkedIn "in" glyph inline (SVG, no external asset — the page already inlines all assets/CSS).profilePublicpref is on — the route already 404s otherwise; project button only whenpublic_report_enabled). The button exposes nothing not already on the public page.?utm_source=linkedin_certon thecertUrlso inbound clicks from LinkedIn are attributable, and/or a Sentry/gamification breadcrumb when the button is present. Keep it optional — don't block the feature on analytics.Tests
scripts/test-gamification.mjs(pure logic) or a small new test:linkedInAddCertUrlproduces a correctly-encoded URL with all params; special chars in name/slug are escaped; omitting issueYear/Month is valid.scripts/test-gamification-api.mjs(already renders the profile HTML at:98): assert the rendered profile page contains alinkedin.com/profile/addlink with the rightname/certUrlfor a public profile, and that a private profile (404) never emits one.Acceptance criteria
QtMesh Creator — Level N, orgQtMesh Cloud, andcertUrl= the profile URL.QtMesh Verified — <project>and the report URL; failing/non-public reports show none.npm run test:gamification+ typecheck green.Non-goals
References
src/index.ts:renderProfilePage(:7859), profile route (:7471), public report route (:7483, gate +fullPageUrlat:7500-7501),escapeXml(:1654), URL-encode precedent (:976), badge routes (:7229+).scripts/test-gamification-api.mjs(:88-104profile render + privacy tests).profile/add?startTask=CERTIFICATION_NAMEURL params).