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 of dest-url: host lowercased, query canonicalised, ampersands percent-encoded. Raw user input is NEVER echoed verbatim.
  • tag-link is 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 in existing retains its existing value (first-wins for UTM).
  • attribute never throws: returns nil on invalid click-id / nil ledger / unknown click-id.
  • For repeated click-id entries in a ledger, attribute returns 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-urlnil; tag-linknil parse-url-rejects-whitespace + tag-link-bad-url-returns-nil
3 dest-url length > 2048 parse-urlnil; tag-linknil 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-id regex caps. JS had no length cap, allowing arbitrary cookie-header injection.
  • commission rate capped at 1.0. JS allowed 2× sale "commissions" silently.
  • Empty/all-NaN inputs to attribute return nil instead of throwing; the link rewriter never crashes.

6. Cross-references

  • attribution-engine/spec.org: the ledger this module's attribute mirrors at the click-id level.
  • referral-program/spec.org: sibling revenue module with the same ID-validation posture (safe-key there, 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.rewriteLinks set data-affiliate-* attrs but did not mutate href. We mirror that in browser.cljs/rewrite-links!: the pihole'd tracking domain would break real navigation. If the rewriter ever SHOULD swap href, that's a one-line change behind a :rewrite-href? flag. Documented in the browser.cljs ns docstring.