links-edge, Cloudflare edge for Link In Bio + click tracking¶
Always-on public surface for the Link In Bio module (plan:
memory/plans/affiliate-click-foundation-2026-07-08.md). Runs entirely on
Cloudflare so the money-making pages + click tracking survive a home-internet
outage. CreatorTrack (on Console, at home) is only the editor + reporting +
Mautic sync; it publishes here and pulls events back.
Cloudflare resources (Phase 1)¶
| Resource | Name | ID / UUID | Provisioned |
|---|---|---|---|
| Account | [email protected]'s Account | 5b4138008d635b79e07af0c7388cd46f |
pre-existing |
| KV namespace | links-map |
eaca8ca562c3454885f2c7e3327a4f14 |
2026-07-08 (Task 1) |
| D1 database | links-events |
4da9b1d3-99c3-47fd-8ce3-33413372bdc4 |
2026-07-08 (Task 1) |
| Worker | links-edge |
(Task 5) | pending |
| Pages project | gus-links |
(Task 6) | pending |
- KV
links-map:linkId -> {destUrl, brand, label}. Read-heavy, rewritten on publish. Feeds the/rredirect. - D1
links-events: append-onlyclicks+capturestables (schema created in Task 2/3). Row ids are ULIDs so the D1 -> CreatorTrack sync is an idempotent upsert.
Auth¶
~/.forge-secrets/cloudflare.env (CLOUDFLARE_API_TOKEN) has Workers/KV/D1/Pages
edit scope. NOTE: the token lacks Account Settings -> Memberships: Read, so
wrangler list subcommands (wrangler kv namespace list) fail with auth 10000,
but create/deploy and all REST API calls work. Provisioning + verification use
the REST API directly (see scripts/ and the plan), not wrangler list.
Routes (LIVE 2026-07-08)¶
All three route to the links-edge Worker; everything else on gusthebass.com stays on the
current tunnel-served static host.
gusthebass.com/r/*-> click redirect (KV lookup -> log click to D1 -> 302)gusthebass.com/c-> email capture (validate -> D1)gusthebass.com/links-> bio page, served from KVpage:<slug>(NOT Cloudflare Pages)
No Cloudflare Pages project. The apex is a proxied tunnel-CNAME, so a path-based Pages custom
domain isn't possible; the Worker serves the published page HTML straight from KV. The
CreatorTrack publish pipeline writes page:<slug> + the <linkId> map into KV.
Auth gotcha¶
The CLOUDFLARE_API_TOKEN in ~/.forge-secrets/cloudflare.env is quoted; set -a; source
strips the quotes, a bare grep|cut does not (a quoted token yields "Invalid Authorization
header"). The token needs zone Workers Routes: Edit to attach routes (added 2026-07-08).
Links v2 wave 2 (built 2026-07-11, NOT yet deployed)¶
New routes (both gusthebass.com and gusoutdoor.co)¶
GET /go/<name>: vanity alias. KValias:<host>:<name>holds a plain-string linkId; once resolved it behaves exactly like/r/<linkId>(same click logging incl. visitor/geo/device, same UTM tagging, same 302). Unknown alias or KV blip fails soft: 302 tohttps://<host>/links.GET /f/<funnelSlug>/<stepN>: funnel step page served from KVpage:<host>:f:<funnelSlug>:<stepN>(stepN 1-based), same headers as the bio page. Missing key or malformed path returns a tiny branded 404 linking back to/links.
Visitor cookie _lvid¶
First-party cookie, crypto.randomUUID(), Max-Age=34560000; Path=/; SameSite=Lax; Secure.
Read on the bio page, /f/*, /r/*, /go/*, and /c; set on those responses when absent
(or when the inbound value is malformed). Logged as visitor_id on both clicks and
captures so CreatorTrack can stitch click -> capture journeys. Router-level: src/visitor.ts
mints/validates, src/index.ts appends the Set-Cookie.
Geo + device¶
country:request.cf.country(clicks had this since day one; captures gain it in 0002).device: coarse UA classification insrc/visitor.ts(mobile|tablet|desktop|bot|unknown, substring rules, no library).
UTM auto-tagging (/r and /go)¶
When the KV linkMap entry has utm === true, the redirect appends utm_source=linkinbio,
utm_campaign=<pageSlug or 'links'>, utm_content=<linkId> to the destination. Existing
query params are preserved; utm params already present on the destination are never
overwritten.
Capture fields¶
POST /c accepts fields (object): keys allowlisted to [a-z_]{1,32}, max 10 keys, values
coerced to trimmed strings capped at 300 chars; stored as a JSON object string in D1
captures.fields. The sync mirrors it to Postgres links.captures.fields jsonb (invalid
JSON -> NULL plus a loud log line). A truthy lm key additionally becomes a Mautic tag
leadmagnet:<value slugified> at fan-out time, so a Mautic campaign can auto-send the lead
magnet email.
KV key shapes (full set)¶
| Key | Value |
|---|---|
<linkId> |
JSON {destUrl, brand, label?, utm?, pageSlug?} |
alias:<host>:<name> |
plain string linkId |
page:<host>:<slug> |
published bio/storefront page HTML |
page:<host>:f:<funnelSlug>:<stepN> |
published funnel step HTML (stepN 1-based) |
vcard:<host>:<slug> |
plain-text vCard 3.0 payload (served by /vcf/<slug>) |
rl:capture:<ip> |
rate-limit counter (TTL 1h) |
vCard route /vcf/<slug> (built 2026-07-12, NOT yet deployed)¶
GET /vcf/<slug> on both zones serves a vCard 3.0 payload straight from KV
vcard:<host>:<slug>. The CreatorTrack publish pipeline owns writing that key (plain-text
vCard body, keyed by host like pages so brands don't collide); the Worker just serves it.
No D1 involvement: downloads are click-tracked CT-side by routing the page button through
/r with destUrl = the /vcf URL, so this route does no logging of its own.
Response headers on hit: Content-Type: text/vcard; charset=utf-8,
Content-Disposition: inline; filename="<slug>.vcf" (inline, NOT attachment: iOS Safari
opens inline vcards straight into the Contacts add sheet; Android also handles inline),
Cache-Control: public, max-age=300. Missing key or malformed slug returns the same tiny
branded 404 as the funnel route, linking back to /links. Sets the _lvid visitor cookie
like every other user-facing route. Handler: src/vcard.ts.
D1 columns added (migrations/0002)¶
clicks: visitor_id TEXT, device TEXT (country already existed from the original
schema). captures: visitor_id TEXT, country TEXT, fields TEXT (JSON string).
Postgres mirror columns (added by a parallel CreatorTrack migration):
links.clicks.visitor_id/country/device text, links.captures.visitor_id/country text,
links.captures.fields jsonb.
Go-live edge upgrades (built 2026-07-12, NOT yet deployed)¶
Instant sync ping (captures AND clicks)¶
After every capture stored in D1, and after every successfully logged click on /r or /go,
the Worker fires a fire-and-forget POST https://app.creatortrack.ai/api/links/sync-ping with
header authorization: Bearer <SYNC_PING_TOKEN> and body {"source":"edge-capture"} or
{"source":"edge-click"}; CreatorTrack triggers an immediate sync run. Gated on the
SYNC_PING_TOKEN Worker secret (wrangler secret put SYNC_PING_TOKEN, deploy checklist item,
never committed); unset means timer-only mode. Runs inside ctx.waitUntil with a 5s timeout,
so it adds zero latency to the capture response or the click 302, and every failure is logged
+ swallowed: home may legitimately be down, and the 30s systemd timer is the backstop that
catches up on reconnect. /go pings once per request (it delegates to the /r handler, the
only place a click is logged). No edge-side throttling on purpose: CT coalesces pings with a
10s cooldown, so a click burst costs CT one sync run, not one per click. Shared helper:
src/syncping.ts.
Already-subscribed flag on /c¶
/c checks D1 for an existing capture with the same email + brand BEFORE inserting (one
indexed lookup via idx_captures_email; email is lowercased on every write, so plain equality
is effectively case-insensitive). The new capture row is still ALWAYS inserted (every signup
event is data); the JSON response gains existing: true|false so the page can show "you're
already on the list" copy. A failed check degrades to existing: false, never blocks the write.
Full request.cf analytics columns (migrations/0003)¶
Both clicks and captures gain, all nullable: city, region, latitude, longitude
(TEXT verbatim off cf, no float parsing), postal_code, metro_code, timezone, continent,
is_eu (INTEGER 0/1 from cf.isEUCountry), asn_org (cf.asOrganization), language
(primary accept-language tag, en-US,en;q=0.9 -> en), browser
(chrome|safari|firefox|edge|instagram|tiktok|facebook|other; in-app browsers classified
first), os (ios|android|windows|mac|linux|other). Extraction + classification live in
src/visitor.ts (getRequestMeta), shared by the click and capture write paths. The sync
mirrors all 13 into matching PG columns on links.clicks / links.captures (CT migration adds
them; sync fails loudly if they are missing, by design).
Per-capture confirmation email (sync side)¶
A capture whose fields carry a numeric we (Mautic email id) with wm == "confirm" gets
Mautic email <we> sent to its contact, new or existing (e.g. merch-drop signup confirmation,
email id 15). Exactly-once by construction (each capture row fans out once). Best-effort like
the brand welcome. Captures with a we override skip the generic brand welcome so one signup
never gets two emails; captures without it keep the existing created-only welcome behavior.
Sync cadence¶
forge-links-sync.timer drops to OnUnitActiveSec=30s + AccuracySec=1s (systemd's default
1min accuracy window would defeat a 30s interval). The ping handles signups AND clicks
instantly; the timer is now a pure backstop (home offline, token unset, missed pings).
Ordered go-live deploy checklist (run by hand, in this order)¶
- CreatorTrack Postgres migration first (adds the 13 mirror columns to
links.clicks+links.captures), otherwise the sync insert fails loudly on its next tick. - Apply D1 migration 0003:
cd forge/infra/links-edge && set -a; source ~/.forge-secrets/cloudflare.env; set +a && npx wrangler d1 execute links-events --remote --file=migrations/0003_city_region.sql - Set the ping secret:
npx wrangler secret put SYNC_PING_TOKEN(same value CT's/api/links/sync-pingexpects). - Deploy the Worker:
npx wrangler deploy sudo systemctl daemon-reload && sudo systemctl restart forge-links-sync.timer- Smoke: POST a test capture to
/ctwice (expectexisting:falsethentrue), confirm the D1 row has city/region/browser/os, watch CT receive theedge-capturesync ping, hit a real/r/<id>(and a/go/<name>) and watch CT receive a singleedge-clickping per request, then confirm the PG rows carry the new columns after the next sync tick.
Deploy steps (wave 2, run by hand, in this order; none run yet)¶
- Verify both live sites still 404
/go/*,/f/*, and/vcf/*(same pre-cutover check as/links). - Confirm the CreatorTrack Postgres migration adding the mirror columns is applied
(
links.clicks.visitor_idetc.), otherwise the sync insert fails loudly on next tick. - Apply the D1 migration:
cd forge/infra/links-edge && set -a; source ~/.forge-secrets/cloudflare.env; set +a && npx wrangler d1 execute links-events --remote --file=migrations/0002_visitor_geo_device_fields.sql - Deploy the Worker (picks up the new
/go/*,/f/*, and/vcf/*routes on both zones from wrangler.toml):npx wrangler deploy - Smoke:
curl -sI https://gusthebass.com/go/nonexistent(expect 302 to/links+Set-Cookie: _lvid=),curl -sI https://gusthebass.com/f/x/1(expect branded 404 + cookie),curl -sI https://gusthebass.com/vcf/nonexistent(expect branded 404 + cookie; after CT publishes a card, a real slug should return 200text/vcardwithContent-Disposition: inline), then a real/r/<id>click and confirm the D1 row has visitor_id/country/device.
Post-deploy REQUIRED step (2026-07-12 incident)¶
wrangler deploy reconciles zone routes to wrangler.toml and DELETES the per-page routes
CreatorTrack's publish adds via the API, silently taking published pages offline. After EVERY
wrangler deploy: run python3 ~/forge/scripts/forge_links_routes_reconcile.py and purge any
cached 404s if a page was hit during the gap. Static toml routes use wildcard patterns
(/links*) because exact patterns do not match query strings.