From f917b1419cf0ed3880c6da18a196ac27c01c5b45 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 18:46:48 +0000 Subject: [PATCH] Keep referral links in affiliates.txt; add Opalstack The affiliate table moves out of affiliates.js into affiliates.txt at the repository root: one ' [landing-only]' line per provider, sorted and de-duplicated by 'bun run affiliates'. The module parses it at load and refuses to start on a bad line, and the tests fail if the file is out of order or lists a domain twice. Opalstack joins with its Rewardful link. Rewardful forwards anywhere, so it is not landing-only and every Opalstack link, deep or not, goes through it. Co-Authored-By: Claude Opus 5.5 (1M context) --- affiliates.txt | 16 ++++++++ package.json | 1 + packages/core/src/affiliates.js | 66 +++++++++++++++++++++++++-------- scripts/affiliates.js | 23 ++++++++++++ test/affiliates.test.js | 48 ++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 affiliates.txt create mode 100644 scripts/affiliates.js diff --git a/affiliates.txt b/affiliates.txt new file mode 100644 index 0000000..6d1855f --- /dev/null +++ b/affiliates.txt @@ -0,0 +1,16 @@ +# Referral links: one provider per line, sorted by domain, no duplicates. +# +# [landing-only] +# +# domain the provider's registrable domain, lowercase, no www. +# Subdomains match too (billing.dedirock.com is dedirock.com). +# referral url where every outbound link to that provider goes instead. https only. +# landing-only the referral endpoint cannot forward to a page of our choosing +# (WHMCS aff.php), so it replaces only links to the provider's +# front door and never a deep link to a named plan. +# +# `bun run affiliates` sorts and de-dupes this file; the test suite fails if it +# is out of order, has a duplicate, or has a line it cannot read. + +dedirock.com https://billing.dedirock.com/aff.php?aff=960 landing-only +opalstack.com https://my.opalstack.com/signup/?via=68ba4d diff --git a/package.json b/package.json index 78dbdd4..53946c4 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "sync": "bun packages/sync/src/cli.js", "migrate": "bun packages/db/src/migrate-cli.js", "build:client": "bun apps/web/build-client.js", + "affiliates": "bun scripts/affiliates.js", "build:brand": "python3 scripts/generate-brand.py", "test": "bun test", "lint": "bun run --bun biome check .", diff --git a/packages/core/src/affiliates.js b/packages/core/src/affiliates.js index 521c8f4..a60834b 100644 --- a/packages/core/src/affiliates.js +++ b/packages/core/src/affiliates.js @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; + /** * Referral links. * @@ -5,15 +7,18 @@ * outbound link to that provider goes through their referral endpoint instead * of straight to their site, and says so where a reader can see it. * - * ## Why this table lives in source and not in the database + * ## Where the table lives: affiliates.txt at the repository root + * + * One line per provider, ` [landing-only]`, sorted and + * de-duplicated (`bun run affiliates` does both). Plain text so a deal can be + * added or dropped without touching code, and still reviewable in a diff. * - * `providers` is a mirror. `sync` pulls the hosting collection from NicheDB - * and 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 — one upsert written differently and the - * deal is silently gone. It would also be invisible: nothing in the repository - * would record who pays us. A table in source makes the commercial - * relationship reviewable in a diff, and it means a provider who is not in the + * Not the database: `providers` is a mirror. `sync` pulls the hosting + * collection from NicheDB and 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. It + * would also be invisible: nothing in the repository would record who pays + * us. A file in the repository also means a provider who is not in the * catalogue yet is already wired up for the day they appear. * * ## Landing-only links, which is what Dedirock's is @@ -30,17 +35,46 @@ * deep link to a particular offer. Sending somebody who clicked a specific * plan to a homepage instead, to earn a commission, would be charging the * reader for our revenue — which is the thing that makes a directory worth - * nothing. An entry without `landingOnly` is free to replace any link. + * nothing. An entry without `landing-only` is free to replace any link: + * Opalstack's Rewardful link does, so every Opalstack link goes through it. */ +/** Read `affiliates.txt`: comments and blank lines skipped, one entry per line. */ +export function parseAffiliates(text) { + const table = {}; + const errors = []; + String(text) + .split('\n') + .forEach((raw, i) => { + const line = raw.replace(/#.*$/, '').trim(); + if (!line) return; + const [domain, url, flag, ...rest] = line.split(/\s+/); + const where = `affiliates.txt:${i + 1}`; + if (!url || rest.length || (flag && flag !== 'landing-only')) { + errors.push(`${where}: expected " [landing-only]"`); + return; + } + let parsed; + try { + parsed = new URL(url); + } catch { + errors.push(`${where}: ${url} is not a URL`); + return; + } + if (parsed.protocol !== 'https:') errors.push(`${where}: ${url} is not https`); + const k = domain.toLowerCase().replace(/^www\./, ''); + if (table[k]) errors.push(`${where}: ${k} is listed twice`); + table[k] = { url, landingOnly: flag === 'landing-only' }; + }); + return { table, errors }; +} + +const FILE = new URL('../../../affiliates.txt', import.meta.url); +const loaded = parseAffiliates(readFileSync(FILE, 'utf8')); +if (loaded.errors.length) throw new Error(`affiliates.txt:\n${loaded.errors.join('\n')}`); + /** domain -> referral endpoint. Keys are registrable domains, lowercase. */ -export const AFFILIATES = { - 'dedirock.com': { - url: 'https://billing.dedirock.com/aff.php?aff=960', - landingOnly: true, - network: 'whmcs', - }, -}; +export const AFFILIATES = loaded.table; /** The rel for a paid link. `sponsored` is the value search engines ask for. */ export const AFFILIATE_REL = 'noopener nofollow sponsored'; diff --git a/scripts/affiliates.js b/scripts/affiliates.js new file mode 100644 index 0000000..ef06a14 --- /dev/null +++ b/scripts/affiliates.js @@ -0,0 +1,23 @@ +/** Sort affiliates.txt by domain and drop duplicate domains (the first line wins). Comments at the top stay put. */ +import { readFileSync, writeFileSync } from 'node:fs'; + +const file = new URL('../affiliates.txt', import.meta.url); +const lines = readFileSync(file, 'utf8').split('\n'); +const head = []; +let i = 0; +for (; i < lines.length && (lines[i].startsWith('#') || !lines[i].trim()); i++) head.push(lines[i]); +const seen = new Map(); +for (const raw of lines.slice(i)) { + const line = raw.trim().replace(/\s+/g, ' '); + if (!line || line.startsWith('#')) continue; + const domain = line + .split(' ')[0] + .toLowerCase() + .replace(/^www\./, ''); + if (seen.has(domain)) console.warn(`dropped duplicate: ${line}`); + else seen.set(domain, [domain, ...line.split(' ').slice(1)].join(' ')); +} +const body = [...seen.keys()].sort().map((d) => seen.get(d)); +while (head.length && !head.at(-1).trim()) head.pop(); +writeFileSync(file, `${[...head, '', ...body].join('\n')}\n`); +console.log(`affiliates.txt: ${body.length} providers`); diff --git a/test/affiliates.test.js b/test/affiliates.test.js index 43e0010..19c706d 100644 --- a/test/affiliates.test.js +++ b/test/affiliates.test.js @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; import { AFFILIATE_REL, AFFILIATES, @@ -7,6 +8,7 @@ import { isFrontDoor, outbound, PLAIN_REL, + parseAffiliates, } from '@r4ck/core'; const DEDIROCK = 'https://billing.dedirock.com/aff.php?aff=960'; @@ -139,3 +141,49 @@ describe('hostOf', () => { expect(hostOf('nope')).toBeNull(); }); }); + +const OPALSTACK = 'https://my.opalstack.com/signup/?via=68ba4d'; + +describe('affiliates.txt', () => { + const file = new URL('../affiliates.txt', import.meta.url); + const entries = () => + readFileSync(file, 'utf8') + .split('\n') + .map((l) => l.replace(/#.*$/, '').trim()) + .filter(Boolean); + + test('reads without errors', () => { + expect(parseAffiliates(readFileSync(file, 'utf8')).errors).toEqual([]); + }); + + test('is sorted by domain with no duplicates, so `bun run affiliates` has nothing to do', () => { + const domains = entries().map((l) => l.split(/\s+/)[0]); + expect(domains).toEqual([...new Set(domains)].sort()); + }); + + test('rejects a line it cannot read, a second listing, and plain http', () => { + const { errors } = parseAffiliates( + 'a.com https://a.com/?r=1\na.com https://a.com/?r=2\nb.com\nc.com http://c.com/\nd.com https://d.com/ sometimes', + ); + expect(errors).toHaveLength(4); + }); +}); + +describe('Opalstack (Rewardful, forwards anywhere)', () => { + test('is in the table and is not landing-only', () => { + expect(AFFILIATES['opalstack.com']).toEqual({ url: OPALSTACK, landingOnly: false }); + }); + + test('every Opalstack link, front door or deep, goes through the referral link', () => { + for (const url of [ + 'https://opalstack.com/', + 'https://www.opalstack.com/pricing', + 'https://my.opalstack.com/signup/', + ]) { + const out = outbound(url, 'opalstack.com'); + expect(out.href).toBe(OPALSTACK); + expect(out.affiliate).toBe(true); + expect(out.rel).toBe(AFFILIATE_REL); + } + }); +});