Skip to main content
← Back to list
01Issue
FeatureIn ProgressSwamp ClubPublic
Assigneesskunk-ape

Relationships

#2179 Referral links: reusable /r/<code> signup link, API + payout

Opened by skunk-ape · 9/16/2026

Problem

An operative's only way to bring someone to the platform is a per-address invite: POST /api/v1/invites creates one PlatformInvite keyed to one email, single-use (status: pending -> accepted), and pays out only once that recruit reaches Tier 5. That shape does not fit how operatives actually recruit — a conference talk, a Discord message, a README badge, a podcast link. Those want one reusable URL that anyone can follow, not a list of addresses typed one at a time.

We want a long-lived referral link: one stable URL per operative, shareable anywhere, reusable by any number of people, that credits the operative when someone who follows it creates an account.

Proposed solution

Three pieces, all in swamp-club. The paired swamp CLI command is a separate issue.

1. A referral-code aggregate

PlatformInvite cannot carry this — it is email-keyed and binds exactly one invitee. Add a new aggregate under lib/domain/referral/ with a repository interface in lib/repositories/, an implementation wired through lib/app/repos.ts, and two record types:

  • referral code — one per operative: opaque random code, operativeId, operativeUsername, createdAt. Get-or-create; an operative's code never changes. No expiry, no revoke surface (see "Why no revocation" below).
  • referral redemption — one per referred account: code, referrerId, refereeId, redeemedAt, rewardGrantedAt. Unique on refereeId, so an account can be credited to at most one referrer, ever.

Run the DDD gate in agent-constraints/planning-conventions.md before adding the aggregate.

2. The API and the landing route

  • POST /api/v1/referral-link — idempotent get-or-create, returns { "code": "...", "url": "https://swamp.club/r/<code>" }. Authenticated by the same credential the CLI already presents: resolveRequestAuth in routes/_middleware.ts mints a synthetic session from a personal swamp_* key, so no new auth work. Build the URL from canonicalOrigin, never a literal.
  • GET /r/<code> — writes the binding cookie and 302s to /signup. Unknown or malformed codes redirect to /signup with no cookie, never a 404: a referral code is not a resource whose existence we confirm.

The binding mechanism already exists and should be reused rather than reinvented: routes/signup.tsx reads ?platform_invite=<id> and writes the short-lived unsigned sc_platform_invite cookie (lib/platform-invite-cookie.ts), which routes/_middleware.ts clears once a session exists. Add a sibling sc_referral cookie with the same properties and the same 1-hour TTL. The link is long-lived; the cookie stays short so an abandoned link cannot bind an unrelated signup hours later in the same browser.

3. Binding and payout

Bind in the same databaseHooks session hook in lib/auth.ts that already calls claimPlatformInvites — it fires on every sign-in and already carries emailVerified, username, createdAt, and the request's cookie header. Model the claim on lib/app/claim-platform-invites.ts: an atomic findOneAndUpdate so it binds once, with the same causality guard (an account that predates the redemption must not be credited).

The payout gate is email verification, not account creation. A bare signup costs an attacker nothing, and this link is public by design — pay only once the referred account has a verified address. The hook already receives emailVerified on every sign-in, so a referral bound at an unverified signup simply pays out at the first verified sign-in.

Points are not granted in app code. Emit a referral_signup telemetry event via reliableTrack with the referrer as distinct_id (the same inversion claim-platform-invites.ts documents for platform_invite_landed — the invitee-owned event credits the wrong side), then add a per-event materialized view in infrastructure/clickhouse/init/03-grant-projections.sql alongside score_grants_from_platform_invite_mv, grant-once on referee_id. Read infrastructure/clickhouse/CLAUDE.md first — a new grant type is prod schema work and needs a backfill entry in score-grants-backfill.sql too.

The point value is open. It should sit well below the 200,000 that platform_invite pays, because that one is gated on a recruit reaching Tier 5 and this one is gated on a verified mailbox.

Anti-abuse

  • Self-referral: reject when refereeId === referrerId.
  • One credit per account, enforced by the unique index on refereeId, not by a read-then-write.
  • The grant-once guard lives in the materialized view keyed on referee_id, so a replayed event cannot double-pay.
  • No per-operative cap at the start. The verified-address gate is the cost floor; add a cap if telemetry shows it is being farmed.

Why no revocation

A referral code is not a secret. The only capability it confers is crediting points to the operative who owns it, so a leaked link is free marketing for its owner rather than a compromise. The one real scenario — someone spamming an operative's link and associating their handle with the spam — is a moderation problem answered by an admin disable switch, not by an operative-facing rotate command. Left out deliberately; revisit if abuse actually shows up.

Out of scope

  • The swamp auth invite-link CLI command — separate issue.
  • Multiple named codes per operative for channel attribution ("conf-talk", "discord"). One stable code per operative for now; a labelled variant is a later addition and the redemption record already carries the code it was redeemed through.
  • Any change to the existing email-invite flow. POST /api/v1/invites, the Tier-5 payout and the First Rule badge all stay exactly as they are.

Notes

  • Existing machinery worth reading before starting: lib/domain/platform-invite/platform-invite.ts, lib/app/claim-platform-invites.ts, lib/platform-invite-cookie.ts, routes/signup.tsx, lib/app/platform-invite-reward.ts.
  • A hidden resource must be indistinguishable from one that never existed — that is why /r/<unknown> redirects rather than 404s.
  • Wire the new repo with infra() for its Mongo spans; the ClickHouse read side uses chInfra().
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 17 MOREREVIEW+ 4 MOREPR_MERGEDSHIPPED

In Progress

9/16/2026, 6:26:06 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
skunk-ape assigned skunk-ape9/16/2026, 4:36:27 PM
Editable. Press Enter to edit.

skunk-ape commented 9/16/2026, 4:20:50 PM

Paired CLI issue: #2180 (swamp auth invite-link, alias rule1) consumes the POST /api/v1/referral-link endpoint specced here. The two can be built in parallel — the CLI side only needs the response shape {code, url} fixed, which this issue owns.

Sibling, not a replacement: #1376 (swamp invite <email>, driving the existing per-address POST /api/v1/invites) stays open and valid. That flow keeps its Tier-5 gate and the First Rule badge; this one is a separate, reusable link with a verified-address gate and a smaller payout.

skunk-ape commented 9/16/2026, 4:32:58 PM

The CLI side moved: #2180 is now swamp invite link (alias rule1) in a new swamp invite command group, not swamp auth invite-link. auth is about who you are; inviting belongs with the other invite flow so neither shape hides from anyone who found the other. Nothing in this issue changes — the endpoint contract, the /r/<code> route and the payout are untouched.

skunk-ape commented 9/16/2026, 9:26:08 PM

Plan approved and implemented — PR: https://github.com/swamp-club/swamp-club/pull/1219

Three decisions diverge from this issue as filed, recorded here so the issue stays the record.

Vocabulary: recruit, not referral. referral is already taken in this codebase — lib/app/marketing/aarrr-model.ts has an AARRR funnel stage named referral meaning "started a free trial". A second meaning would have collided. recruit also matches the existing quest deed id recruit_first ("New Blood"). This renames the endpoint to POST /api/v1/recruit-link; the {code, url} body is unchanged, and #2180 has been told.

Reward: full platform-invite parity, not a smaller number. The issue left the value open and suggested it sit well below 200,000. It is now exactly 200,000 plus the First Rule badge, gated on the recruit reaching ordinal 5 — identical to the email-invite lane. The reasoning that changed: a recruit link brings a NET NEW user, which is what the platform lane is for, and the collective lane pays no ledger points at all. The verified-mailbox gate moved to the BIND while the ordinal gate governs the PAYOUT, which is stricter than either existing lane and preserves the anti-farm property — 200,000 for a verified mailbox alone would be farmable with a public link.

Cross-lane precedence. Both lanes can recruit the same person, so exactly one may bind an account: a followed token beats an inferred address. The recruit claim stands down when a platform-invite token cookie is present; the invite claim's ADDRESS arm yields when the recruit lane owns the account; its token arm is untouched. Precedence keys on the bind SUCCEEDING and reads ownership from the repository rather than the cookie — either shortcut pays 400,000 for one human.

Two smaller notes: the binding cookie lives seven days rather than the invite cookie's hour, because requireEmailVerification means it is not read until the recruit clicks the link in their mailbox and this lane has no address arm to catch a late verifier. And the issue's "gated on a recruit reaching Tier 5" is inaccurate — PLATFORM_INVITE_REWARD_TIER_ORDINAL = 5 is an ORDINAL (Marsh Skulker, 8,000 points, tier 2); tier 5 begins at ordinal 13 / 25,000,000. Filed separately.

Sign in to post a ripple.