Cloudflare Worker (Hono) that serves as the backend API for Cmdr. Handles licensing (Paddle webhooks, Ed25519 key
generation, activation codes in KV), telemetry (crash reports, downloads, update checks in D1), admin endpoints, and
cron-based notifications. Deployed at api.getcmdr.com (license.getcmdr.com remains as a permanent alias for existing
app versions).
| File | Purpose |
|---|---|
src/index.ts |
Hono app assembly: mounts route modules, wires scheduled handler |
src/types.ts |
Shared types (Bindings), constants, and helpers (auth, validation) |
src/licensing.ts |
Routes: /activate, /validate, /webhook/paddle, /admin/generate |
src/admin.ts |
Routes: /admin/stats, /admin/downloads, /admin/active-users, /admin/crashes |
src/telemetry.ts |
Routes: /crash-report, /update-check/:version, /download/:version/:arch |
src/likes.ts |
Routes: /likes/:slug (GET, POST, DELETE, OPTIONS) |
src/error-report.ts |
Route: POST /error-report (multipart upload to R2, Discord notify) |
src/error-report-eviction.ts |
Eviction logic: 8/6 GB watermarks, KV lock, recompute helper |
src/discord.ts |
Discord webhook client (single-retry on 429, drop-on-failure) |
src/scheduled.ts |
Cron handler functions (crash notifications, aggregation, DB size, eviction) |
src/license.ts |
Short code + license key generation, LicenseType enum |
src/paddle.ts |
HMAC-SHA256 webhook verification, constantTimeEqual |
src/paddle-api.ts |
Paddle REST client: transaction/subscription/customer fetch |
src/email.ts |
Resend email delivery (HTML + plain text, multi-seat support) |
src/device-tracking.ts |
Device set helpers: prune stale devices, alert threshold |
src/license.test.ts, src/paddle.test.ts |
Vitest tests |
src/device-tracking.test.ts |
Tests for device tracking helpers |
src/admin-stats.test.ts |
Tests for /admin/stats endpoint and activation counter |
src/admin-endpoints.test.ts |
Tests for /admin/downloads, /admin/active-users, /admin/crashes |
src/crash-report.test.ts |
Tests for POST /crash-report endpoint |
src/download-and-update-check.test.ts |
Tests for download redirect and update check routes |
src/scheduled.test.ts |
Tests for cron handler (crash notifications, aggregation) |
scripts/generate-keys.js |
Ed25519 key pair generation (run once at setup) |
scripts/setup-cf-infra.sh |
Cloudflare KV namespace provisioning |
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | / |
none | Health check |
| POST | /webhook/paddle |
HMAC sig | Purchase completed → generate & email key(s) |
| POST | /activate |
none | Exchange short code → full cryptographic key |
| POST | /validate |
none | Check subscription status via Paddle API |
| POST | /admin/generate |
Bearer token | Manual key generation (customer service / testing) |
| GET | /admin/stats |
Bearer token | Activation count + device count (for analytics dashboard) |
| GET | /admin/downloads |
Bearer token | Aggregated download data by day/version/arch/country |
| GET | /admin/active-users |
Bearer token | Aggregated daily active users by version/arch |
| GET | /admin/crashes |
Bearer token | Aggregated crash data by day/crash site/signal |
| GET | /download/:version/:arch |
none | Log download to D1, 302 → GitHub |
| POST | /crash-report |
none | Ingest crash report to D1 |
| POST | /error-report |
none | Multipart upload (zip + meta) → R2, Discord notify |
| GET | /update-check/:version |
none | Log update check to D1 (deduped), 302 → latest.json |
Sandbox (dev) and live (prod) are completely separated. They share the same codebase but have different Paddle accounts, API keys, price IDs, webhook secrets, and notification destinations. There is no cross-environment routing.
PADDLE_ENVIRONMENT (in wrangler.toml and overridable as a wrangler secret) controls which Paddle API base URL and
API key the server uses. Set to "sandbox" by default (from wrangler.toml). The deployed worker overrides it to
"live" via a wrangler secret.
| Secret / var | .dev.vars (local dev) |
Wrangler secret (deployed worker) |
|---|---|---|
PADDLE_ENVIRONMENT |
"sandbox" (from wrangler.toml) |
"live" |
PADDLE_WEBHOOK_SECRET_SANDBOX |
Sandbox secret | Sandbox secret (for safety) |
PADDLE_WEBHOOK_SECRET_LIVE |
n/a | Live secret |
PADDLE_API_KEY_SANDBOX |
Sandbox API key | n/a |
PADDLE_API_KEY_LIVE |
n/a | Live API key |
PRICE_ID_COMMERCIAL_SUBSCRIPTION |
Sandbox price ID | Live price ID |
PRICE_ID_COMMERCIAL_PERPETUAL |
Sandbox price ID | Live price ID |
ED25519_PRIVATE_KEY |
Private key hex | Same private key hex |
RESEND_API_KEY |
Resend key | Same Resend key |
CRASH_NOTIFICATION_EMAIL |
veszelovszki@gmail.com |
Recipient email for crash alerts |
DISCORD_WEBHOOK_URL |
Same webhook URL | Discord webhook for error reports |
R2_ACCOUNT_ID |
Same account ID | For minting presigned R2 URLs |
R2_ACCESS_KEY_ID |
Same access key | R2 S3-compat access key (read OK) |
R2_SECRET_ACCESS_KEY |
Same secret | Paired secret for R2 access key |
R2/KV bindings (declared in wrangler.toml, provisioned via ./scripts/setup-cf-infra.sh):
| Binding | Type | Purpose |
|---|---|---|
ERROR_REPORTS_BUCKET |
R2 bucket | Stores error report zip bundles (cmdr-error-reports, 90-day TTL) |
ERROR_REPORT_META |
KV namespace | total_bytes counter + eviction_in_progress lock for the eviction logic |
Paddle dashboards: sandbox | live
DISCORD_WEBHOOK_URL posts notifications to the #error-reports channel of the Cmdr Discord server. The URL is the
secret (anyone holding it can post to that channel), so it lives only as a wrangler secret, never in the repo.
To create or rotate the webhook:
- Open the Cmdr Discord server → right-click
#error-reports→ Edit Channel → Integrations → Webhooks. - To rotate: click the existing webhook → Delete Webhook, then New Webhook. To create fresh: just New Webhook. Name it "Cmdr error reports".
- Click Copy Webhook URL. URL shape:
https://discord.com/api/webhooks/<id>/<token>. - Store it as a wrangler secret (run from anywhere in the repo):
pnpm --filter @cmdr/api-server exec wrangler secret put DISCORD_WEBHOOK_URL - Smoke-test it landed correctly:
curl -H "Content-Type: application/json" -d '{"content":"webhook test"}' "<webhook-url>"
Rate limit: 30 messages/min per webhook. The Worker should retry once on Retry-After, then drop with a console.error
We don't run our own queue infra for an internal channel.
The error-report Worker mints 7-day presigned GET URLs for the zip bundles in R2 and embeds them in Discord
notifications. R2 bindings can't presign on their own, so the Worker uses the S3-compatible API via aws4fetch and
three secrets: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY.
Current values: stored in David's password store (Bitwarden). The secrets also live as Cloudflare Worker secrets
(wrangler secret list to confirm).
To create (or rotate) the R2 access key:
- https://dash.cloudflare.com → R2 Object Storage → Manage R2 API Tokens (top right).
- Create API Token. Name:
cmdr-error-reports-presign. - Permission: Object Read (read-only is enough; writes go through the R2 binding, not the S3 key).
- Scope: Apply to specific buckets only →
cmdr-error-reports. - TTL: forever (or match your rotation policy).
- Click Create API Token. The token page shows THREE values that are displayed ONCE:
- Access Key ID →
R2_ACCESS_KEY_ID - Secret Access Key →
R2_SECRET_ACCESS_KEY - Account ID (also shown in the dashboard top-right / R2 URL) →
R2_ACCOUNT_ID
- Access Key ID →
- Save all three into Bitwarden before leaving the page.
- Set the three as wrangler secrets:
pnpm --filter @cmdr/api-server exec wrangler secret put R2_ACCOUNT_ID pnpm --filter @cmdr/api-server exec wrangler secret put R2_ACCESS_KEY_ID pnpm --filter @cmdr/api-server exec wrangler secret put R2_SECRET_ACCESS_KEY
- To rotate: create a fresh token first, set the new secrets, deploy, then delete the old token from the R2 API Tokens page.
Gotcha when deploying: if your shell has CLOUDFLARE_API_TOKEN set, wrangler deploy uses that instead of the
interactive OAuth login. The token must have the Workers R2 Storage: Edit permission or the deploy fails with
Authentication error [code: 10000] on the R2 bucket precheck. Fix at https://dash.cloudflare.com/profile/api-tokens.
One-shot workaround without editing the token:
CLOUDFLARE_API_TOKEN= pnpm --filter @cmdr/api-server exec wrangler deploy (empties the env var for that command, falls
back to the OAuth login).
verifyPaddleWebhookMulti tries both PADDLE_WEBHOOK_SECRET_LIVE and PADDLE_WEBHOOK_SECRET_SANDBOX when verifying
incoming webhooks. This is a safety net; in practice, the sandbox dashboard sends webhooks only to the sandbox
destination (ngrok for local dev), and the live dashboard sends only to the live destination (api.getcmdr.com).
Paddle webhook → HMAC verify (tries both live + sandbox secrets)
→ idempotency check (KV key: "transaction:{id}", 7-day TTL)
→ Paddle API: fetch customer details
→ per seat: generateLicenseKey() → generateShortCode() → KV.put(code, {fullKey, orgName})
→ sendLicenseEmail() via Resend
→ KV.put(idempotencyKey, "processed")
App activation: POST /activate → KV.get(shortCode) → return fullKey
Subscription validation: POST /validate → Paddle API transactions + subscriptions
→ HTTP 200 + ValidationResponse on success or invalid transaction (Paddle 404)
→ HTTP 502 + { error: "upstream_error" } if Paddle API unreachable or returns server error
→ if deviceId present: track device in KV (devices:{seatTransactionId}), log to Analytics Engine
→ if device count >= 6 and not recently alerted: send alert email to legal@getcmdr.com
Download redirect: GET /download/:version/:arch → write to D1 (fire-and-forget) → 302 to GitHub Releases
Crash report: POST /crash-report → validate payload (size + required fields) → hash IP with daily salt → write to D1 (fire-and-forget via waitUntil) → 204
Update check proxy: GET /update-check/:version → hash IP with daily salt → INSERT OR IGNORE into D1 (fire-and-forget) → 302 to latest.json
Cron (every 12h): scheduled handler runs three jobs:
1. Crash notifications: query un-notified crash_reports → group by top_function → mark notified → email summary
2. Daily aggregation (00:00 UTC only): aggregate update_checks → daily_active_users, prune raw data older than 7 days
3. DB size check (00:00 UTC only): query pragma_page_count/pragma_page_size → email alert if over 100 MB
A single scheduled handler runs every 12 hours (0 */12 * * *). It runs three independent jobs, each in its own
try-catch so one failure doesn't block the others:
-
Crash notifications (every invocation): queries
crash_reports WHERE notified_at IS NULL, sorted newest-first, marks rows as notified, then sends an email via Resend with one row per crash report (When, Env, ID, Site, Signal, Version). Marks before sending to prefer missed notifications over duplicates. Pre-fix-* this grouped bytop_function; the per-row layout is easier to scan and includes the user-visibleCRASH-XXXXXid. RequiresCRASH_NOTIFICATION_EMAILandRESEND_API_KEY. -
Daily aggregation (00:00 UTC only): aggregates yesterday's
update_checksintodaily_active_usersviaINSERT OR IGNORE ... GROUP BY, then prunes raw update checks older than 7 days. Idempotent via existence check. -
DB size check (00:00 UTC only): queries D1 pragma for total database size. Sends an alert email if over 100 MB.
-
Daily eviction sweep (00:00 UTC only):
handleDailyEvictionSweeprecomputestotal_bytesfrom R2 ground truth (the per-upload KV counter is racy and drifts), then triggerstryEvictif still over 8 GB. Idempotent. Catches drift from concurrent uploads or a Worker dying mid-eviction.
The default export uses the object form ({ fetch, scheduled }) required for cron support. The Hono app is also
exported as a named export so tests can use app.request().
Short code format: CMDR-XXXX-XXXX-XXXX using 31 unambiguous chars (excludes 0/O/1/I/L). Rejection sampling avoids
modulo bias (max unbiased byte = 256 - (256 % 31)).
License key format: base64(JSON payload).base64(Ed25519 signature). Payload contains: email, transactionId,
issuedAt, type, organizationName.
License types: commercial_subscription | commercial_perpetual
Idempotency: 7-day KV entry per transaction. If email throws after KV writes but before the idempotency key is set, Paddle's retry re-generates and re-sends. Intentional design.
Price ID → license type mapping: getLicenseTypeFromPriceId() in paddle-api.ts maps Paddle price IDs (from
PRICE_ID_* env vars) to license types. Unknown price IDs fall back to commercial_subscription for backwards
compatibility.
Security: Admin bearer token compared with constantTimeEqual (XOR-accumulate, timing-safe). All secrets are
Cloudflare secrets (wrangler secret put), never in wrangler.toml. /admin/stats uses a dedicated ADMIN_API_TOKEN
secret, separate from the Paddle webhook secrets used by /admin/generate.
Activation counter: /activate increments a KV counter at _meta:activation_count on each successful activation.
Read by /admin/stats. The counter starts from zero when deployed; initialize via the CF API if historical count is
needed.
D1 for telemetry: Crash reports, downloads, and update checks are stored in D1 (binding: TELEMETRY_DB, database:
cmdr-telemetry). Migrations live in migrations/. Apply with wrangler d1 migrations apply cmdr-telemetry before
deploying changes that add new tables. The only remaining Analytics Engine dataset is DEVICE_COUNTS for fair-use
monitoring. All other state (license codes, activation counter, device sets) lives in Cloudflare KV. Short codes never
expire (perpetual licenses last forever); subscription validity is checked live via Paddle API.
Validation error granularity: /validate distinguishes "Paddle says invalid" (HTTP 200 + status: "invalid") from
"Paddle is unreachable" (HTTP 502 + { error: "upstream_error" }). paddle-api.ts throws PaddleApiError on
network/5xx errors and returns null on 404 (transaction not found). This lets the desktop app fall back to cached
status on transient Paddle outages instead of overwriting a valid "active" cache with "invalid."
Download tracking: Uses D1 (binding: TELEMETRY_DB, table: downloads). One row per download event with
app_version, arch, country, and continent. D1 write is fire-and-forget via waitUntil + .catch(() => {}).
Update check tracking: Uses D1 (binding: TELEMETRY_DB, table: update_checks). Counts active users (free +
licensed) by proxying update checks through GET /update-check/:version. Each unique (date, hashed_ip, app_version,
arch) combo gets one row (INSERT OR IGNORE with a UNIQUE constraint handles deduplication for free). IP is hashed with
SHA-256 + daily salt for deduplication without storing PII. D1 write is fire-and-forget via waitUntil +
.catch(() => {}). The cron handler aggregates raw data into the daily_active_users summary table daily.
Crash report tracking: Uses D1 (binding: TELEMETRY_DB, table: crash_reports). Receives crash reports from the
desktop app via POST /crash-report. Columns: hashed_ip, app_version, os_version, arch, signal, top_function,
backtrace, build_mode ('release' / 'debug', nullable for legacy rows), short_id (CRASH-XXXXX, nullable for legacy
rows). IP is hashed with SHA-256 + daily salt (same pattern as update checks). Validates payload size (max 64 KB),
required fields, and the shape of optional fields before writing. D1 write is fire-and-forget via waitUntil +
.catch(() => {}). No authentication required.
Device tracking (fair use): On each /validate call with a deviceId, the server tracks the device in KV
(devices:{seatTransactionId}) and logs to Analytics Engine (binding: DEVICE_COUNTS, dataset: cmdr_device_counts).
Devices older than 90 days are pruned on each write. If 6+ devices are active and no alert was sent in the past 30 days,
an internal email is sent to legal@getcmdr.com via Resend. Device tracking is fire-and-forget and never affects the
validation response. The KV value stores a DeviceSet with device hashes mapped to last-seen timestamps plus an
optional lastAlertedAt. Device tracking is per seat: each seat in a multi-seat purchase has its own transaction ID and
its own 6-device allowance.
Update check proxy: GET /update-check/:version routes update checks through the worker to count all users (free +
licensed). Without this, there's no signal for how many people actually run the app (Umami only tracks website visitors
and download tracking only captures installs).
Error report R2 key shape: error-reports/{prod|dev}/{yyyy-mm-dd}/{ERR-XXXXX}-{uuid}.zip. The env segment (prod
for release builds, dev for debug builds, inferred from meta.buildMode) keeps dev-run reports out of the production
sort order. Legacy keys (error-reports/{yyyy-mm-dd}/..., pre-env-prefix) still exist; eviction reads the date segment
via extractDateSegment which handles both shapes. The 90-day R2 lifecycle drains the legacy shape naturally. No
migration needed.
Error report eviction (8/6 GB watermarks + lifecycle): Three layers keep the bucket bounded.
- On-upload eviction: every
POST /error-reportschedulestryEvictinwaitUntil(...). Iftotal_bytes(KV) > 8 GB andeviction_in_progress(KV, 60-s TTL lock) isn't set, lists R2 objects undererror-reports/, sorts oldest-first by the embeddedyyyy-mm-ddsegment (viaextractDateSegment, which handles both new and legacy key shapes) then byuploaded, deletes until ≤ 6 GB, then resets the counter to the recomputed ground truth. - Daily cron sweep: corrects KV drift by recomputing from R2 and re-running
tryEvict. - R2 lifecycle rule: 90-day expiration applied at provisioning time via
scripts/setup-cf-infra.sh.
The KV counter is approximate (read-then-write, no atomic increment; same as _meta:activation_count). Both the daily
sweep and post-eviction recompute correct it. R2 deletes are idempotent; concurrent evictors deleting the same oldest
object cause no harm.
Error report Discord notifications: Every upload triggers a Discord embed with a 7-day presigned R2 GET URL. Uses
the R2 S3-compatible API via aws4fetch (AwsClient.sign with signQuery: true + X-Amz-Expires). 7 days is R2's max
for presigned URLs. Convenience of click-to-download outweighs leak risk because only the maintainer accesses the
#error-reports channel.
Short ID generation: generateShortId(prefix, len) in license.ts produces IDs like ERR-A2345 from the same
unambiguous alphabet (23456789ABCDEFGHJKMNPQRSTUVWXYZ) as license short codes. Rejection sampling avoids modulo bias.
The error report route does NOT regenerate the id server-side. It validates the client-supplied meta.id against the
shape ^ERR-[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{5}$ and uses it as-is. On the astronomically rare R2 key collision (same
id + same date + UUID clash), the route retries with a fresh UUID (never a fresh id), so the user-visible id from the
preview dialog stays stable through to the toast.
pnpm dev # starts wrangler dev server on :8787
pnpm test # vitest unit testswrangler is a local devDependency, not global. From inside apps/api-server/ use npx wrangler …. From the repo root
(no cd needed), use the pnpm filter form:
pnpm --filter @cmdr/api-server exec wrangler secret put DISCORD_WEBHOOK_URL
pnpm --filter @cmdr/api-server exec wrangler deployBoth forms resolve the same local wrangler binary.
ngrok http 8787 --url unsickerly-acclivitous-lala.ngrok-free.devThe ngrok domain is stable across restarts. The Paddle sandbox notification destination already points to
https://unsickerly-acclivitous-lala.ngrok-free.dev/webhook/paddle.
For quick local testing of crypto verification and the activation UI, use /admin/generate. It accepts the Paddle
sandbox webhook secret as the bearer token:
curl -X POST http://localhost:8787/admin/generate \
-H "Authorization: Bearer $(grep PADDLE_WEBHOOK_SECRET_SANDBOX apps/api-server/.dev.vars | cut -d= -f2-)" \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","type":"commercial_subscription","organizationName":"Test Corp"}'Returns code (short code like CMDR-ABCD-EFGH-1234) and type. Change type to commercial_perpetual for a
perpetual license. These keys use synthetic transaction IDs (manual-*), so they won't pass server validation via
/validate (offline crypto + UI testing only).
For end-to-end testing including /validate, use the Paddle sandbox checkout flow (see
README.md).
See README.md. Requires setting up a Paddle client-side token and a default payment link in the sandbox dashboard. This is an interactive, human-driven flow.
cd apps/api-server
npx wrangler d1 migrations apply cmdr-telemetry # apply any new D1 migrations first
npx wrangler deployDeployed to api.getcmdr.com via Cloudflare custom domain (declared in wrangler.toml [[routes]]).
license.getcmdr.com is a permanent alias for existing app versions. Fallback URL:
cmdr-license-server.veszelovszki.workers.dev. The cron trigger (0 */12 * * *) is declared in wrangler.toml under
[triggers] and is deployed automatically with wrangler deploy.
- 522 on
api.getcmdr.com: Custom domain isn't routing to the Worker. Checknpx wrangler deployoutput showsapi.getcmdr.com (custom domain). The[[routes]]block inwrangler.tomlmay be missing, or a DNS record is blocking it. - "externally managed DNS records": Delete the manual DNS record via CF API/dashboard, then redeploy.
- "kv bindings require kv write perms": API token missing "Workers KV Storage: Edit". Update at https://dash.cloudflare.com/profile/api-tokens.
- Workers.dev works but custom domain doesn't: Domain binding failed. Check error in deploy output.
Commercial prices use external tax_mode. Commercial customers pay tax on top of the listed price. This is
configured per-price in the Paddle dashboard (both sandbox and live).
Decision: PADDLE_ENVIRONMENT env var controls sandbox vs live routing, rather than inferring from transaction IDs.
Why: Both sandbox and live transactions use the same txn_ prefix, so there's no reliable way to detect the
environment from a transaction ID. An explicit env var is unambiguous. wrangler.toml defaults to "sandbox" for local
dev; the deployed worker overrides to "live" via a wrangler secret.
Decision: Price IDs stored as env vars (PRICE_ID_*) rather than hardcoded. Why: Sandbox and live Paddle
accounts have different price IDs for the same products. Env vars let each environment use its own IDs without code
changes. .dev.vars has sandbox IDs; wrangler secrets have live IDs.
Decision: No hard enforcement of device limits; the server never rejects a validation because of device count. Why: Suspension is a manual decision after human review. The goal is to detect obvious key sharing (one key on 6+ devices), not to restrict legitimate power users. Alert threshold is 6 because 3-4 Macs is normal, 5 is plausible, 6 is hard to explain as one person. The threshold is not published in the ToS to avoid gaming.
Gotcha: verifyAdminAuth uses a manual type annotation for c instead of Hono's Context type. Why: Using
Context<{ Bindings: Bindings }> would require importing Hono's internal generic types and threading them through. The
manual shape { env: Bindings; req: { header: ... } } is simpler and avoids coupling to Hono internals.
Gotcha: Paddle preserves custom_data key casing exactly as passed in from checkout. Why: The checkout passes
organizationName (camelCase), and both webhook payloads and API responses return it in camelCase. The code must use
organizationName, not organization_name.
Gotcha: verifyPaddleWebhookMulti tries both webhook secrets even though environments are separated. Why:
Safety net. If a sandbox webhook somehow reaches the production endpoint (or vice versa), it still verifies rather than
silently failing. Costs one extra HMAC check on mismatch.
Gotcha: The activation counter (_meta:activation_count in KV) uses read-then-write, which has a race condition
under concurrent /activate requests. Why: KV doesn't support atomic increment. The counter is approximate; if
exact counts matter, query the CF API to list KV keys, or switch to Durable Objects / D1.
Gotcha: The /download/:version/:arch redirect maps x86_64 → x64 in the filename. Why: tauri-action names
the Intel DMG Cmdr_<ver>_x64.dmg, but the rest of the codebase (URL path, D1 telemetry, website data attrs, Rust
target triple, uname -m) consistently uses x86_64. Mapping at the boundary keeps everything else canonical. Same
convention is already used in .github/workflows/release.yml when reading DMG sizes for latest.json.
Gotcha: Validators for optional fields posted from the Rust desktop client must tolerate both null and
undefined, not just undefined. Why: serde Option::None serializes as JSON null, not as an absent key.
#[serde(skip_serializing_if = "Option::is_none")] would omit the key but is rejected by specta's unified mode (the
struct is part of a Tauri command surface). An old crash file read by a new client surfaces missing fields as None,
the client posts "buildMode": null, and a !== undefined-only check rejects it, losing exactly the upgrade-window
reports we want to keep. Pattern: value !== undefined && value !== null && <shape check>. See telemetry.ts
validateCrashReportShape for the canonical form.
Runtime: hono, @noble/ed25519, resend Dev: wrangler, vitest, typescript, eslint, prettier
Decision: Paddle as Merchant of Record (not Stripe, Gumroad, LemonSqueezy, or Polar). Why: All-inclusive pricing (5% + $0.50, no hidden non-US or EU payout fees), aggregate monthly payouts (one invoice for accountant instead of per-transaction), handles global VAT/GST calculation and remittance, established reputation (Sketch, etc.). On a $29 sale: $1.95 fee → $27.05 net. At 30k sales, saves ~$7k/year vs LemonSqueezy. Stripe was rejected because solo-dev handling VAT in 27+ EU countries is impractical (Stripe is a payment processor, not an MoR).
Decision: BSL 1.1 license with free personal use (supersedes earlier AGPL + trial model). Why: The AGPL + trial model felt pushy for hobbyists (trial countdown, nagware). BSL gives friction-free personal use (no nags), clear commercial terms (businesses know they must pay), and simpler enforcement (title bar shows license type, honor system beats trial timers). Source converts to AGPL-3.0 after 3 years per release.
See also: apps/desktop/src/lib/licensing/CLAUDE.md (full frontend licensing feature overview)