Affiliate engine: spec
Table of Contents
1. Purpose
The affiliate engine turns a partner plus a destination URL into a
tracked link, computes a commission on a sale, and attributes a
click-id back to a partner from a ledger. The core is a cross-host
.cljc namespace, wal-sh.adtech.affiliate-engine.core; the browser
layer owns the clock and the RNG. The core never reads either.
ORACLE: src/wal_sh/adtech/affiliate_engine/core.cljc (pure),
browser.cljs (window._affiliate).
2. Requirements
Input contract. Bad inputs throw ex-info unless a row says otherwise.
| Field | Type | Bound |
|---|---|---|
partner |
partner-id string OR partner map | :partner-id matches [A-Za-z0-9_-]{1,64} |
dest-url |
string http(s) URL or nil/empty | ≤ 2048 chars; no whitespace; no control chars |
opts :click-id |
string | matches [A-Za-z0-9_-]{1,128} (REQUIRED) |
opts :timestamp |
non-neg integer ≤ 1e15 | REQUIRED |
opts :sub-id |
string | partner-id shape (default "organic") |
opts :publisher-id / :tracking-domain |
string | partner-id / DNS-host shape |
sale |
non-neg finite double | ≤ max-sale (1e9) |
rate |
non-neg finite double | ≤ 1.0 (NOT > 1) |
Composition order:
tag-link= build-tracking-params ∘ canonicalise-destination ∘ validate-inputs ∘ resolve-partner.- Destination canonicalisation happens BEFORE tracking-param construction. This is the XSS gate.
3. Contract signature
Pure (core.cljc):
| Fn | Signature | Returns |
|---|---|---|
tag-link |
(partner dest-url opts) |
{:url :click-id :partner :destination} or nil |
commission |
(sale rate) |
double; dollars, rounded to 2dp half-up |
attribute |
(click-id ledger) / (... partners) |
partner map or nil |
parse-url |
(s) |
{:scheme :host :port :path :query :fragment} or nil |
serialize-url |
(parsed-map) |
string; canonical, percent-encoded |
merge-pairs |
(existing additions) |
vec of [k v]; existing-key wins |
find-partner |
(partner-id) / (... partners) |
partner map or nil |
valid-click-id? / valid-partner-id? |
(s) |
boolean |
record-sale / ledger-totals |
ledger ops | new vec / totals map |
format-usd |
(x) |
"$X.XX" |
Browser surface (browser.cljs):
window._affiliate = { partners, tagLink, commission, attribute, recordSale,
ledger, totals, setCookie, getCookie, rewriteLinks, formatUsd }
The browser layer owns the clock and the RNG; core never reads either.
4. Output guarantees (invariants)
(parse-url (:url r))is non-nil for any non-nil(tag-link ...). Invariant: emitted tracking URLs always re-parse.(:destination r)is the parse-and-reserialized form ofdest-url: host lowercased, query canonicalised, ampersands percent-encoded. Raw user input is NEVER echoed verbatim.tag-linkis pure for fixed(partner, dest-url, click-id, timestamp): byte-identical bytes on JVM + CLJS.commission∈[0, sale + 0.005](rounding slack of ½¢); always finite.merge-pairs: every key inexistingretains its existing value (first-wins for UTM).attributenever throws: returns nil on invalid click-id / nil ledger / unknown click-id.- For repeated click-id entries in a ledger,
attributereturns the latest by:timestamp(stable tie-break: last in vector).
5. Negative-state catalog (property tests)
All entries have rejection or fallback property tests in
test/wal_sh/adtech/affiliate_engine/core_prop_test.cljc unless noted.
| # | Input shape | Specified behaviour | Test |
|---|---|---|---|
| 1 | dest-url = "javascript:alert(1)" (or data:, file:, ftp:) |
parse-url returns nil; tag-link returns nil (no throw; caller can skip) |
parse-url-rejects-foreign-schemes + tag-link-bad-url-returns-nil (50 cases) |
| 2 | dest-url contains whitespace / newline / control chars |
parse-url → nil; tag-link → nil |
parse-url-rejects-whitespace + tag-link-bad-url-returns-nil |
| 3 | dest-url length > 2048 |
parse-url → nil; tag-link → nil |
parse-url-rejects-overlong |
| 4 | dest-url = nil or "" |
NOT a rejection; fall back to partner :base-url (documented contract) |
tag-link-with-partner-id-string |
| 5 | partner-id not in registry |
tag-link throws ex-info "unknown or missing partner" |
tag-link-rejects-unknown-partner |
| 6 | partner-id contains HTML/JS chars (e.g. <script>) |
valid-partner-id? rejects; tag-link throws; partner-id never echoed in URL |
tag-link-injection-attempt-in-partner-rejected |
| 7 | click-id with whitespace / newline / tab / empty / overlong |
tag-link throws ex-info "click-id must be 1..128 alphanumeric/_-" |
tag-link-rejects-bad-click-id (50 cases) |
| 8 | commission sale < 0 OR rate < 0 |
throws ex-info "must be non-neg finite" |
commission-rejects-bad-sale / -bad-rate (50 cases each) |
| 9 | commission rate > 1.0 |
throws ex-info "rate must be ≤ 1.0" (commissions can't exceed sale) |
commission-rejects-rate-over-one |
| 10 | commission sale or rate = ##NaN / ##Inf |
throws (finite check fires first) | commission-rejects-nan-inf + commission-rejects-bad-sale |
| 11 | attribute on unknown / invalid / nil click-id |
returns nil (NEVER throws; link-rewriter relies on this) |
attribute-unknown-click-id-returns-nil + attribute-total-on-bad-inputs (50 cases) |
| 12 | URL with duplicate query params + tracking adds same key | first-wins: existing UTM/key preserved, tracking value dropped | merge-preserves-existing-keys (100 cases) + merge-preserves-existing-utm |
| 13 | Value with smuggled & or = |
percent-encoded on re-serialization (%26 / %3D); cannot inject new param |
serialize-url-encodes-injected-ampersand |
| 14 | Same click-id recorded twice with different partner-ids | attribute returns the LATEST entry (highest :timestamp; stable tie-break) |
attribute-resolves-to-latest |
| 15 | 1000× identical tag-link calls |
byte-identical output (no hidden RNG / clock; caller injects both) | no-drift-over-1000-tag-links |
| 16 | tag-link cross-host vector |
fixed inputs → fixed bytes (array-map preserves key order; ASCII encoder portable) | cross-host-tag-link-vector |
What we tightened beyond the JS source:
- Strict URL grammar (no
javascript:, no userinfo, no IPv6 literal). The JS source had no validation at all. partner-id/click-idregex caps. JS had no length cap, allowing arbitrary cookie-header injection.commissionrate capped at 1.0. JS allowed 2× sale "commissions" silently.- Empty/all-NaN inputs to
attributereturnnilinstead of throwing; the link rewriter never crashes.
6. Cross-references
- attribution-engine/spec.org: the ledger this module's
attributemirrors at the click-id level. - referral-program/spec.org: sibling revenue module with the
same ID-validation posture (
safe-keythere,valid-partner-id?here). - ORACLE:
src/wal_sh/adtech/affiliate_engine/core.cljc(pure)src/wal_sh/adtech/affiliate_engine/browser.cljs(window._affiliate)test/wal_sh/adtech/affiliate_engine/core_prop_test.cljc(properties)
7. Open questions
- The JS source
LinkGenerator.rewriteLinkssetdata-affiliate-*attrs but did not mutatehref. We mirror that inbrowser.cljs/rewrite-links!: the pihole'd tracking domain would break real navigation. If the rewriter ever SHOULD swaphref, that's a one-line change behind a:rewrite-href?flag. Documented in thebrowser.cljsns docstring.