Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions affiliates.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Referral links: one provider per line, sorted by domain, no duplicates.
#
# <domain> <referral url> [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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
66 changes: 50 additions & 16 deletions packages/core/src/affiliates.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { readFileSync } from 'node:fs';

/**
* Referral links.
*
* A few providers pay a commission on a referred order. Where one does, the
* 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, `<domain> <referral url> [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
Expand All @@ -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 "<domain> <url> [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';
Expand Down
23 changes: 23 additions & 0 deletions scripts/affiliates.js
Original file line number Diff line number Diff line change
@@ -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`);
48 changes: 48 additions & 0 deletions test/affiliates.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import {
AFFILIATE_REL,
AFFILIATES,
Expand All @@ -7,6 +8,7 @@ import {
isFrontDoor,
outbound,
PLAIN_REL,
parseAffiliates,
} from '@r4ck/core';

const DEDIROCK = 'https://billing.dedirock.com/aff.php?aff=960';
Expand Down Expand Up @@ -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);
}
});
});
Loading