Skip to content

feat(verification): badge SVG version segment + version-pinned URLs (#3524 stage 3)#3595

Merged
bokelley merged 2 commits intomainfrom
bokelley/per-version-badges-stage3
Apr 30, 2026
Merged

feat(verification): badge SVG version segment + version-pinned URLs (#3524 stage 3)#3595
bokelley merged 2 commits intomainfrom
bokelley/per-version-badges-stage3

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Stage 3 of #3524. Badges and embed code can now carry an explicit AdCP version.

Before After
`Media Buy Agent (Spec)` `Media Buy Agent 3.0 (Spec)`
`Media Buy Agent (Spec + Live)` `Media Buy Agent 3.1 (Spec + Live)`

The legacy URL `/badge/{role}.svg` still works — it serves the highest active version, so embedded badges in the wild auto-upgrade as agents earn newer versions.

New routes

  • `GET /api/registry/agents/{url}/badge/{role}/{version}.svg` — version-pinned SVG. Returns the (Spec)/(Live) qualifier earned at exactly that version, or "Not Verified" if the agent never earned a badge there.
  • `GET /api/registry/agents/{url}/badge/{role}/{version}/embed` — version-pinned embed snippets (HTML + Markdown). Alt text includes the version: `AAO Verified Media Buy Agent 3.0`.

Path-segment form (not query string) for clean shields.io-style cache keys. Version validates against `^[1-9][0-9]{0,3}.[0-9]{1,3}$` at the route boundary so 400-on-invalid is distinguishable from 200-no-badge.

What does NOT change

  • Verification panel still renders one row per role — Stage 4 splits into one row per (role, version).
  • brand.json enrichment shape unchanged — Stage 5 adds the `badges[]` array with version detail.
  • Legacy `/badge/{role}.svg` and `/badge/{role}/embed` keep working unchanged (just gain a version segment in the rendered label when a badge exists).

Defense in depth

`renderBadgeSvg` validates `adcpVersion` against the same regex as the JWT signer. A malformed value drops from the rendered label rather than failing the image. Asymmetry vs. the JWT signer (which fails closed) is deliberate: a missing badge image is worse for the buyer than a less-specific one. ETag covers `role + version + modes` so cache invalidates on transitions.

Test plan

  • 8 new unit tests for the version segment in `renderBadgeSvg`: embed-when-set, omit-when-absent, drop-on-malformed (SQL-injection-shaped), reject-leading-zero major, reject-full-semver, double-digit minor preservation, "Not Verified" labels never carry a version
  • 130/130 unit tests pass total
  • TypeScript typecheck clean

Stage tracker

🤖 Generated with Claude Code

bokelley and others added 2 commits April 29, 2026 21:24
…3524 stage 3)

Badge labels now read 'Media Buy Agent 3.0 (Spec)' instead of
'Media Buy Agent (Spec)'. Legacy /badge/{role}.svg keeps serving the
highest active version (auto-upgrades on new badges) so embedded
badges in the wild aren't broken.

New version-pinned routes:
- GET /api/registry/agents/{url}/badge/{role}/{version}.svg
- GET /api/registry/agents/{url}/badge/{role}/{version}/embed

Path-segment form, not query string — cleaner shields.io-style cache
keys. Version validates against ^[1-9][0-9]{0,3}\.[0-9]{1,3}$ at the
route boundary so 400-on-invalid is distinguishable from 200-no-badge.

renderBadgeSvg gains an options object with optional adcpVersion. A
malformed value drops from the label rather than failing the image —
unlike the JWT signer (which fails closed because a partial token is
a downgrade vector), a missing badge image is worse for the buyer
than a less-specific one. ETag covers role+version+modes.

8 new tests for the version segment: embed-when-set, omit-when-absent,
drop-on-malformed including SQL-injection-shaped, reject-leading-zero
major, reject-full-semver, double-digit minor preservation, and
'Not Verified' labels never carry a version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Security review:

- HIGH: rate-limit all four badge routes. The legacy /badge/{role}.svg
  + /badge/{role}/embed pair was already missing agentReadRateLimiter
  before this stage; the new version-pinned routes inherited the gap.
  All four are anonymous and hit Postgres on every request, which made
  them a DB enumerator across the registry. Fixed by wrapping each
  route with agentReadRateLimiter (the same limiter already applied
  to /compliance and /compliance/history).
- MED: filter the legacy ETag's adcp_version through the same shape
  regex renderBadgeSvg uses. A poisoned DB row with control characters
  (CR/LF, NUL) in the column would have crashed the response with
  ERR_INVALID_CHAR when Node serialized the ETag header. Same DB-row
  gracefulness the SVG path explicitly handles.
- LOW: new test pins the multi-version revoke transition invariant
  (3.0 revoked while 3.1 active should leave 3.1 untouched, so the
  legacy URL upgrades cleanly).

Code review:

- Tighten changeset wording: validation is shape-identical to the JWT
  signer / DB CHECK but length-bounded only at the route boundary
  (the SVG render-path filter is shape-only since its callers are
  already gated upstream).
- Response-shape consistency: the .svg routes returned text/plain
  ("Invalid agent URL") but the embed routes returned JSON. Aligned
  to JSON across the badge route family.
- escapeMdAltText forward defense in buildEmbedResponse so a future
  caller passing user-controlled alt text can't break the markdown
  link syntax with an unescaped `]`.

OpenAPI coverage: registered both new routes (.svg + embed) so the
coverage check stays green; CI caught the gap on the original push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley force-pushed the bokelley/per-version-badges-stage3 branch from 1ae81e7 to 480b8cf Compare April 30, 2026 01:24
@bokelley bokelley merged commit 50b050f into main Apr 30, 2026
13 checks passed
@bokelley bokelley deleted the bokelley/per-version-badges-stage3 branch April 30, 2026 01:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant