Skip to content

fix(ads): keep terminal ads inside the box at narrow widths - #175

Merged
ralyodio merged 1 commit into
masterfrom
fix/terminal-url-width
Jul 31, 2026
Merged

ralyodio merged 1 commit into
masterfrom
fix/terminal-url-width

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Follow-up to #174, which surfaced this while verifying the house-ad rotation.

Two independent ways a terminal ad could print outside the frame it was asked to fit in. Both bite hardest at the minimum width (44 cols → 40 usable columns).

1. Unwrapped call-to-action (advertiser-controlled)

Headline and body both go through wrapText. The CTA row did not. row clamps its padding at Math.max(0, …), so an over-long CTA didn't error — it silently emitted a row wider than the box.

Before, cols=44 with a 63-char CTA:

+-- SPONSORED -----------------------------+
|                                          |
| Click here to start your completely free thirty day trial today: |   <-- 68 chars
|                                          |
+----------------- ads by crawlproof.com --+

After — wraps and hard-splits like every other copy field:

| Click here to start your completely free |
| thirty day trial today:                  |

This one is worth a look on its own merits: it means advertiser copy could break the box, on any width, for any slot.

2. House ad click URL too long for the box

https://crawlproof.com/?utm_source=house-ad&utm_medium=motd

59 chars, or 66 once a publisher's &s=<surface> tag is appended — well past the 40 columns available at cols=44, so the renderer pushed it below the frame. House ads are currently 100% of terminal inventory, so this was every fill.

Added /h, a house-ad click redirector mirroring the existing /a/<impression_id>, re-applying the utm params server-side where characters are free. Printed URL is now 24 chars (31 with a surface tag) and sits inside the box at every width:

+-- CRAWLPROOF ADS ------------------------+
|                                          |
| Ads without JavaScript                   |
| One HTTP call, plain text back. No       |
| iframe, no tracking pixel.               |
|                                          |
| See the docs:                            |
| https://crawlproof.com/h?s=motd          |
|                                          |
+----------------- ads by crawlproof.com --+

Attribution is unchanged except utm_medium moves motd → terminal, with the surface recorded as utm_content. The same URL is printed into BBS screens and SSH banners, not just MOTDs, so the old fixed motd was wrong anyway.

Deliberately NOT fixed

A paid click URL is /a/<uuid> = 61 chars (68 with a surface tag). That cannot fit 40 columns on this domain — https://crawlproof.com/ alone is 23. Those still fall outside the frame, which is the documented, tested behavior (moves an over-wide URL below the box rather than cutting it), and is better than hard-wrapping a link the reader can no longer select in one go.

Making paid URLs fit at 44 needs short impression codes — a schema change on the click-metering path. That's a separate change and doesn't belong in a layout fix. Happy to do it as a follow-up if you want it.

Verification

  • npm run typecheck clean.
  • Full suite 1233 passed, 7 skipped (1221 + 12 new).
  • New tests confirmed to fail on master — 5/12 with both files reverted, and the house-URL test verified to fail when only lib/ads/house.ts is reverted (it measures the URL the code actually builds, re-hosted on the production origin, rather than a stand-in).

🤖 Generated with Claude Code

Two separate ways a terminal ad could print outside the frame it was
asked to fit in. Both bite hardest at the minimum width, 44 cols, which
leaves only 40 usable columns.

1. The call-to-action was the one piece of advertiser copy that was never
   wrapped. Headline and body both go through wrapText; the CTA row was
   emitted whole. `row` clamps its padding at Math.max(0, ...), so an
   over-long CTA didn't error -- it silently produced a row wider than
   the box. A 63-character CTA at cols=44 rendered a 68-character row,
   i.e. advertiser-controlled copy could break the frame. It now wraps
   (and hard-splits) exactly like the headline and body.

2. The house ad's click URL spelled its utm params out inline:

     https://crawlproof.com/?utm_source=house-ad&utm_medium=motd

   That is 59 characters, or 66 once a publisher's &s=<surface> tag is
   appended -- far past the 40 columns a 44-col box has, so the renderer
   pushed it below the frame on every narrow fill. Since house ads are
   currently 100% of terminal inventory, that was every fill.

   Added /h, a house-ad click redirector mirroring the existing
   /a/<impression_id>, which re-applies the utm params server-side where
   characters are free. The printed URL is now 24 characters, or 31 with
   a surface tag, and sits inside the box at every supported width.
   Attribution is unchanged apart from utm_medium moving from "motd" to
   "terminal", with the surface itself recorded as utm_content -- the
   same URL is printed into BBS screens and SSH banners, not just MOTDs.

Not fixed, and left deliberately: a *paid* click URL is
/a/<uuid> = 61 characters (68 with a surface tag), which cannot fit 40
columns on this domain. Those still fall outside the frame, as designed
and as covered by the existing "moves an over-wide URL below the box"
test. Making them fit needs short impression codes -- a schema change on
the click-metering path, which does not belong in a layout fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio marked this pull request as ready for review July 31, 2026 12:34
@ralyodio
ralyodio merged commit 8698f06 into master Jul 31, 2026
8 checks passed
ralyodio added a commit that referenced this pull request Jul 31, 2026
)

Follow-up to #175, which fixed the house ad's click URL but left the paid
one. A paid terminal ad printed:

  https://crawlproof.com/a/<uuid>            61 chars
  https://crawlproof.com/a/<uuid>?s=motd     68 chars

against the 40 usable columns of a 44-col box, so the URL was always
pushed outside the frame. Two changes bring it to 37:

1. A 12-character base62 short code addresses the impression instead of
   its UUID. The length is derived, not picked: 40 usable columns minus
   the 25-character "https://crawlproof.com/a/" prefix leaves 15, and 12
   keeps three columns of headroom for a longer origin while still being
   71 bits. Generated with rejection sampling — a plain byte % 62 would
   have skewed the first four symbols and quietly cost entropy the width
   budget is already tight on.

2. The publisher's surface tag moves out of the URL and onto the
   impression row. As "&s=<tag>" it cost up to 35 more columns of a box
   that has 40; /a/<code> now reads it back off the row to build
   utm_content. It is also queryable there, which the query string never
   was.

Entropy matters because the code is all that stands between a stranger
and a click charge on someone else's campaign, so this is deliberately
well above the ~42 bits a 7-character code would have given.

Compatibility, both directions:

- /a/<id> still accepts UUIDs. Click URLs printed before this change are
  sitting in people's MOTDs, SSH banners and BBS screens and are never
  reissued; they must keep resolving. The ?s= query form is still read as
  a fallback for the same reason.
- Migrations here are applied by hand, so the deploy can land before the
  schema. Both new columns are additive and nullable, the impression
  insert retries without them if they are missing, and the click URL
  falls back to the UUID form. A wide URL is cosmetic; a dropped
  impression is a lost sale. Either apply order is safe.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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