Skip to content

Commit ef0f049

Browse files
committed
Download tracking via CF Analytics Engine
- License server: `GET /download/:version/:arch` logs version, arch, country, continent to Analytics Engine, then 302s to GitHub Releases - Website: `release.ts` routes downloads through `PUBLIC_DOWNLOAD_BASE_URL` when set, falls back to direct GitHub links - Docs: updated `CLAUDE.md`, `infrastructure.md`, `deploy-website.md` - Also updated all license server NPM packages to latest
1 parent c331143 commit ef0f049

12 files changed

Lines changed: 904 additions & 418 deletions

File tree

CONTRIBUTING.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,21 @@ Or add it via CLI like:
131131
Since the agent shares the context with your IDE/client, enabling the MCP server makes the tools available to the agent
132132
automatically.
133133

134+
## Cloudflare access (license server)
135+
136+
The license server is a Cloudflare Worker. To deploy it or run `wrangler` commands, you need a Cloudflare API token.
137+
138+
1. Go to https://dash.cloudflare.com/profile/api-tokens → **Create Token****Custom token**
139+
2. Permissions: `Account / Workers Scripts / Edit`, `Account / Analytics Engine / Read`
140+
3. Account resources: the Cmdr account only
141+
4. Add to `~/.zshenv` (sourced for all shells, including non-interactive agent sessions):
142+
```sh
143+
export CLOUDFLARE_API_TOKEN="your-token"
144+
```
145+
5. Restart your shell or `source ~/.zshenv`
146+
147+
Wrangler picks up `CLOUDFLARE_API_TOKEN` automatically — no `wrangler login` needed.
148+
134149
## Infrastructure access (maintainers)
135150

136151
If you have SSH access to the production server (`ssh hetzner`) and credentials for services like Umami, Cloudflare,

apps/license-server/CLAUDE.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@ license keys, stores short activation codes in KV, and emails keys via Resend.
1818

1919
## Routes
2020

21-
| Method | Path | Auth | Purpose |
22-
| ------ | ----------------- | ------------ | -------------------------------------------------- |
23-
| GET | `/` || Health check |
24-
| POST | `/webhook/paddle` | HMAC sig | Purchase completed → generate & email key(s) |
25-
| POST | `/activate` || Exchange short code → full cryptographic key |
26-
| POST | `/validate` || Check subscription status via Paddle API |
27-
| POST | `/admin/generate` | Bearer token | Manual key generation (customer service / testing) |
21+
| Method | Path | Auth | Purpose |
22+
| ------ | -------------------------- | ------------ | -------------------------------------------------- |
23+
| GET | `/` || Health check |
24+
| POST | `/webhook/paddle` | HMAC sig | Purchase completed → generate & email key(s) |
25+
| POST | `/activate` || Exchange short code → full cryptographic key |
26+
| POST | `/validate` || Check subscription status via Paddle API |
27+
| POST | `/admin/generate` | Bearer token | Manual key generation (customer service / testing) |
28+
| GET | `/download/:version/:arch` || Log download to Analytics Engine, 302 → GitHub |
2829

2930
## Data flow
3031

@@ -39,6 +40,8 @@ Paddle webhook → HMAC verify (live + sandbox secrets)
3940
App activation: POST /activate → KV.get(shortCode) → return fullKey
4041
4142
Subscription validation: POST /validate → Paddle API transactions + subscriptions
43+
44+
Download redirect: GET /download/:version/:arch → log to Analytics Engine → 302 to GitHub Releases
4245
```
4346

4447
## Key patterns
@@ -63,6 +66,10 @@ Cloudflare secrets (`wrangler secret put`), never in `wrangler.toml`.
6366
**No database:** All state lives in Cloudflare KV. Short codes never expire (perpetual licenses last forever);
6467
subscription validity is checked live via Paddle API.
6568

69+
**Download tracking:** Uses Cloudflare Analytics Engine (binding: `DOWNLOADS`, dataset: `cmdr_downloads`).
70+
`writeDataPoint` is fire-and-forget. Data schema: indexes=[version], blobs=[version, arch, country, continent],
71+
doubles=[1]. Query via CF Analytics Engine SQL API.
72+
6673
## Dependencies
6774

6875
Runtime: `hono`, `@noble/ed25519`, `resend` Dev: `wrangler`, `vitest`, `typescript`, `eslint`, `prettier`

apps/license-server/README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ them to customers via Resend.
4646
URL `https://cmdr-license-server.veszelovszki.workers.dev/webhook/paddle`, and tick event `transaction.completed`.
4747
11. Paddle (sandbox): Click "..." → Edit destination → copy "Secret key". (Looks like `pdl_ntfset_01keh5q...`)
4848
12. TODO: Paddle live!
49-
13. Cloudflare: (first time only) `npx wrangler login` to log in to Cloudflare.
49+
13. Cloudflare: Set `CLOUDFLARE_API_TOKEN` in `~/.zshenv` (see [CONTRIBUTING.md](../../CONTRIBUTING.md#cloudflare-access-license-server) for how to create one). Alternatively, run `npx wrangler login` for browser-based OAuth (interactive only, won't work for agents).
5050
14. Cloudflare: Set secrets (supports both live and sandbox simultaneously):
5151
- `npx wrangler secret put PADDLE_WEBHOOK_SECRET_SANDBOX` - From sandbox webhook (step 11)
5252
- `npx wrangler secret put PADDLE_WEBHOOK_SECRET_LIVE` - From live webhook (once approved)
@@ -104,12 +104,14 @@ Then open http://localhost:3333 and click "Buy Cmdr".
104104

105105
## Endpoints
106106

107-
| Method | Path | Description |
108-
| ------ | ----------------- | -------------------------------------------------- |
109-
| `GET` | `/` | Health check |
110-
| `POST` | `/webhook/paddle` | Paddle webhook (generates and emails license) |
111-
| `POST` | `/validate` | Validate license key (returns subscription status) |
112-
| `POST` | `/admin/generate` | Manual license generation (requires auth header) |
107+
| Method | Path | Description |
108+
| ------ | -------------------------- | -------------------------------------------------- |
109+
| `GET` | `/` | Health check |
110+
| `POST` | `/webhook/paddle` | Paddle webhook (generates and emails license) |
111+
| `POST` | `/activate` | Exchange short code for full cryptographic key |
112+
| `POST` | `/validate` | Validate license key (returns subscription status) |
113+
| `POST` | `/admin/generate` | Manual license generation (requires auth header) |
114+
| `GET` | `/download/:version/:arch` | Log download to Analytics Engine, 302 → GitHub |
113115

114116
## Architecture decisions
115117

apps/license-server/package.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,20 @@
2020
},
2121
"dependencies": {
2222
"@noble/ed25519": "^3.0.0",
23-
"hono": "^4.12.2",
24-
"resend": "^6.6.0"
23+
"hono": "^4.12.5",
24+
"resend": "^6.9.3"
2525
},
2626
"devDependencies": {
27-
"@cloudflare/workers-types": "^4.20260131.0",
28-
"@eslint/js": "^9.39.2",
29-
"eslint": "^9.39.2",
27+
"@cloudflare/workers-types": "^4.20260301.1",
28+
"@eslint/js": "^10.0.1",
29+
"eslint": "^10.0.2",
3030
"eslint-config-prettier": "^10.1.8",
31-
"eslint-plugin-prettier": "^5.5.4",
32-
"globals": "^17.0.0",
33-
"prettier": "^3.7.4",
31+
"eslint-plugin-prettier": "^5.5.5",
32+
"globals": "^17.4.0",
33+
"prettier": "^3.8.1",
3434
"typescript": "^5.9.3",
35-
"typescript-eslint": "^8.52.0",
36-
"vitest": "^4.0.16",
37-
"wrangler": "^4.61.1"
35+
"typescript-eslint": "^8.56.1",
36+
"vitest": "^4.0.18",
37+
"wrangler": "^4.70.0"
3838
}
3939
}

apps/license-server/src/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
type Bindings = {
1414
// KV namespace for license code -> full key mappings
1515
LICENSE_CODES: KVNamespace
16+
// Analytics Engine for download tracking
17+
DOWNLOADS: AnalyticsEngineDataset
1618
// Paddle webhook secrets (both optional to support gradual rollout)
1719
PADDLE_WEBHOOK_SECRET_LIVE?: string
1820
PADDLE_WEBHOOK_SECRET_SANDBOX?: string
@@ -434,4 +436,29 @@ app.post('/admin/generate', async (c) => {
434436
return c.json({ code: shortCode, type, organizationName: organizationName ?? null })
435437
})
436438

439+
// Download redirect — tracks version, arch, and country, then redirects to GitHub Releases
440+
const validArchitectures = new Set(['aarch64', 'x86_64', 'universal'])
441+
const versionPattern = /^\d+\.\d+\.\d+$/
442+
443+
app.get('/download/:version/:arch', (c) => {
444+
const { version, arch } = c.req.param()
445+
446+
if (!versionPattern.test(version) || !validArchitectures.has(arch)) {
447+
return c.json({ error: 'Invalid version or architecture' }, 400)
448+
}
449+
450+
const cf = c.req.raw.cf as { country?: string; continent?: string } | undefined
451+
const country = cf?.country ?? 'unknown'
452+
const continent = cf?.continent ?? 'unknown'
453+
454+
// Fire-and-forget — writeDataPoint is non-blocking
455+
c.env.DOWNLOADS.writeDataPoint({
456+
indexes: [version],
457+
blobs: [version, arch, country, continent],
458+
doubles: [1],
459+
})
460+
461+
return c.redirect(`https://github.com/vdavid/cmdr/releases/download/v${version}/Cmdr_${version}_${arch}.dmg`, 302)
462+
})
463+
437464
export default app

apps/license-server/wrangler.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ compatibility_date = "2025-01-01"
99
binding = "LICENSE_CODES"
1010
id = "64418f5cb33e4c629fe0ccf51be76399"
1111

12+
# Analytics Engine for download tracking (created automatically on first write)
13+
[[analytics_engine_datasets]]
14+
binding = "DOWNLOADS"
15+
dataset = "cmdr_downloads"
16+
1217
[vars]
1318
# Non-secret config
1419
PRODUCT_NAME = "Cmdr"

apps/website/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ PUBLIC_PADDLE_ENVIRONMENT=sandbox
1616
# Get the list UUID from the Listmonk admin UI: Lists > your list > Settings
1717
PUBLIC_LISTMONK_LIST_UUID=
1818

19+
# Download tracking redirect
20+
# Routes downloads through the license server for server-side analytics.
21+
# Production: https://license.getcmdr.com — downloads become /download/{version}/{arch}
22+
# Leave empty to link directly to GitHub Releases (useful for dev).
23+
PUBLIC_DOWNLOAD_BASE_URL=
24+
1925
# Umami analytics (self-hosted, cookieless)
2026
# Leave PUBLIC_UMAMI_WEBSITE_ID empty to disable tracking (for example, in dev env)
2127
PUBLIC_UMAMI_HOST=https://your-umami-instance.example.com

apps/website/src/lib/release.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ import latestRelease from '../../public/latest.json'
22

33
export const version = latestRelease.version
44

5-
const base = `https://github.com/vdavid/cmdr/releases/download/v${version}`
5+
const downloadBase = import.meta.env.PUBLIC_DOWNLOAD_BASE_URL
6+
const githubBase = `https://github.com/vdavid/cmdr/releases/download/v${version}`
67

7-
export const dmgUrls = {
8-
aarch64: `${base}/Cmdr_${version}_aarch64.dmg`,
9-
x86_64: `${base}/Cmdr_${version}_x86_64.dmg`,
10-
universal: `${base}/Cmdr_${version}_universal.dmg`,
8+
function dmgUrl(arch: string): string {
9+
if (downloadBase) return `${downloadBase}/download/${version}/${arch}`
10+
return `${githubBase}/Cmdr_${version}_${arch}.dmg`
1111
}
1212

13-
/** @deprecated Use dmgUrls instead */
14-
export const dmgUrl = dmgUrls.universal
13+
export const dmgUrls = {
14+
aarch64: dmgUrl('aarch64'),
15+
x86_64: dmgUrl('x86_64'),
16+
universal: dmgUrl('universal'),
17+
}
1518

1619
function formatBytes(bytes: number): string {
1720
return `${Math.round(bytes / (1024 * 1024))} MB`

docs/guides/deploy-website.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ Set the following (get values from the relevant dashboards):
158158
| `PUBLIC_LISTMONK_LIST_UUID` | Listmonk admin > Lists > your list > Settings |
159159
| `PUBLIC_UMAMI_HOST` | Your Umami instance URL (for example, `https://analytics.example.com`) |
160160
| `PUBLIC_UMAMI_WEBSITE_ID` | Umami > Settings > Websites > getcmdr.com > ID |
161+
| `PUBLIC_DOWNLOAD_BASE_URL` | `https://license.getcmdr.com` — routes downloads through the license server for analytics. Leave empty to link directly to GitHub. |
161162

162163
### 9. Set up Docker network and do initial deploy
163164

docs/specs/analytics-plan.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,15 +194,18 @@ implementing the above:
194194
- [x] Add download event tracking to `Download.astro`
195195
- [x] Add env vars to `.env.example` and deployment config
196196
- [x] Set `PUBLIC_UMAMI_HOST` and `PUBLIC_UMAMI_WEBSITE_ID` in production deployment env
197-
- [ ] Verify data flows in Umami dashboard
197+
- [x] Verify data flows in Umami dashboard
198198

199199
### Milestone 2: Download tracking (CF Worker)
200-
- [ ] Decide: new route in license server vs separate Worker
201-
- [ ] Enable Analytics Engine in `wrangler.toml`
202-
- [ ] Implement `/download/:version/:arch` redirect endpoint
203-
- [ ] Update website download links to use the redirect
204-
- [ ] Update release workflow if `latest.json` needs changes
205-
- [ ] Test end-to-end: download via redirect, verify event logged
200+
- [x] Decide: new route in license server (avoids another deployment target)
201+
- [x] Enable Analytics Engine in `wrangler.toml` (binding: `DOWNLOADS`, dataset: `cmdr_downloads`)
202+
- [x] Implement `/download/:version/:arch` redirect endpoint in `index.ts`
203+
- [x] Update website `release.ts` to use redirect when `PUBLIC_DOWNLOAD_BASE_URL` is set
204+
- [x] Update `.env.example` with `PUBLIC_DOWNLOAD_BASE_URL`
205+
- [x] Update license server `CLAUDE.md` with new route and Analytics Engine docs
206+
- [x] Set `PUBLIC_DOWNLOAD_BASE_URL` in website production env
207+
- [x] Deploy license server: `cd apps/license-server && pnpm cf:deploy`
208+
- [ ] Test end-to-end: download via redirect, verify event logged in Analytics Engine
206209

207210
### Milestone 3: In-app analytics (PostHog)
208211
- [ ] Create PostHog project, get API key

0 commit comments

Comments
 (0)