From 82fbd316818e911d078006db1c3bdee8c4b40497 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Fri, 25 Sep 2026 11:41:28 +0000
Subject: [PATCH] Route outbound links through a referral endpoint where we
have one
Adds Dedirock's referral link and the machinery to use it. The table of
deals lives in packages/core/src/affiliates.js rather than in the
providers table, because that table is a mirror: sync upserts it with
coalesce(excluded.x, providers.x) on every column, so a referral URL
stored there would arrive as null on each sync and survive only by the
grace of that coalesce. In source it is reviewable in a diff, and a
provider who is not in the catalogue yet is already wired up for the day
they appear -- which is the case here, since nothing in the hosting
collection names Dedirock today.
The awkward part is that Dedirock's endpoint is WHMCS aff.php, measured
on 2026-09-25: it answers 301 to https://dedirock.com, sets
WHMCSAffiliateID for ninety days, and ignores both `url` and `goto`.
Passing aff=960 inline on a billing page sets no cookie at all. So there
is no way to earn the referral and land on a named plan.
Rather than take the commission anyway, a landing-only link replaces a
link to the provider's front door -- where nothing is lost, because that
is where it lands regardless -- and never replaces a deep link to a
particular offer. Dropping somebody who clicked a named plan onto a
homepage to earn a commission is how a directory stops being worth
reading.
Every affiliate link is labelled where a reader can see it and carries
rel="sponsored". Incidentally that is now accurate: the order button
previously declared sponsored on every provider, paid or not.
20 tests for the substitution rule, the deep-link guard and the
subdomain match (notdedirock.com must not borrow the deal). Full suite
green at 59 pass / 2 skip.
Co-Authored-By: Claude Opus 5 (1M context)
---
apps/web/public/styles.css | 15 ++++
apps/web/src/views/parts.jsx | 31 +++++++
apps/web/src/views/providers.jsx | 10 +--
apps/web/src/views/servers.jsx | 18 ++--
packages/core/src/affiliates.js | 107 +++++++++++++++++++++++
packages/core/src/index.js | 1 +
test/affiliates.test.js | 141 +++++++++++++++++++++++++++++++
7 files changed, 305 insertions(+), 18 deletions(-)
create mode 100644 packages/core/src/affiliates.js
create mode 100644 test/affiliates.test.js
diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css
index ee12079..d7916aa 100644
--- a/apps/web/public/styles.css
+++ b/apps/web/public/styles.css
@@ -157,6 +157,21 @@ pre code {
.small {
font-size: 0.82rem;
}
+/*
+ * The visible half of an affiliate disclosure. It sits inside the link it is
+ * disclosing, on a plain text link and on the primary order button alike, so
+ * it inherits colour and steps down only in size and weight.
+ */
+.aff-mark {
+ margin-left: 0.4em;
+ font-size: 0.7em;
+ font-weight: 400;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ opacity: 0.7;
+ vertical-align: 0.08em;
+ cursor: help;
+}
.visually-hidden,
.skip {
position: absolute;
diff --git a/apps/web/src/views/parts.jsx b/apps/web/src/views/parts.jsx
index 23c3c57..2c20e79 100644
--- a/apps/web/src/views/parts.jsx
+++ b/apps/web/src/views/parts.jsx
@@ -7,6 +7,7 @@ import {
fmtGb,
fmtMoney,
KIND_LABELS,
+ outbound,
paramsFrom,
toggle,
toggleBucket,
@@ -16,6 +17,36 @@ import { raw } from 'hono/html';
/** Shared pieces: the rack-unit row, facet rail, chips, agent panel, sparkline. */
+/**
+ * A link off the site to a provider.
+ *
+ * Routed through a referral endpoint where we have a deal with that provider,
+ * and labelled as one when it is, because a reader is entitled to know which
+ * links pay us. `rel` carries `sponsored` in that case, which is what search
+ * engines ask for on a paid link.
+ */
+export function Outbound({ url, domain, class: cls, children }) {
+ const out = outbound(url, domain);
+ if (!out.href) return null;
+ return (
+
+ {children}
+ {out.affiliate ? : null}
+
+ );
+}
+
+export function AffiliateMark() {
+ return (
+
+ affiliate
+
+ );
+}
+
export const flag = (code) =>
/^[A-Z]{2}$/.test(code)
? String.fromCodePoint(...[...code].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65))
diff --git a/apps/web/src/views/providers.jsx b/apps/web/src/views/providers.jsx
index 664d9a2..1090af6 100644
--- a/apps/web/src/views/providers.jsx
+++ b/apps/web/src/views/providers.jsx
@@ -1,7 +1,7 @@
import { config } from '@r4ck/config';
import { countryName, fmtMoney } from '@r4ck/core';
import { Layout } from './Layout.jsx';
-import { AgentPanel, Automation, flag, initials, ProviderCard, Unit } from './parts.jsx';
+import { AgentPanel, Automation, flag, initials, Outbound, ProviderCard, Unit } from './parts.jsx';
const csv = (v) =>
String(v ?? '')
@@ -249,11 +249,9 @@ export function Provider({ user, detail }) {