From 919dc278c9328e01f33c6310a186404a449887ec Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Thu, 5 Nov 2026 09:43:10 -0800 Subject: [PATCH 1/2] docs: move the API reference from the README to docs/api.md The docs site has no API page for QR codes, webhooks, SDK endpoints, health or debug routes, so the list moves to docs/ rather than being cut. Adds the template routes and the template-path resolve route, which were missing. --- README.md | 201 +--------------------------------------------- docs/README.md | 3 +- docs/api.md | 211 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 201 deletions(-) create mode 100644 docs/api.md diff --git a/README.md b/README.md index e9eb4ac..0ba06f8 100644 --- a/README.md +++ b/README.md @@ -115,208 +115,9 @@ docker run -d \ See [DOCKER.md](DOCKER.md) for complete deployment guide. -## API Reference - -### Links - -#### Create a Link - -`userId` is optional. When provided, the link is scoped to that user (multi-tenant mode). When omitted, the link has no owner (single-tenant mode). - -```bash -POST /api/links -Content-Type: application/json - -{ - "userId": "user-uuid", - "originalUrl": "https://example.com", - "title": "My Link", - "description": "Summer campaign link", - "iosAppStoreUrl": "https://apps.apple.com/app/id123456", - "androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.example", - "webFallbackUrl": "https://example.com/product/123", - "appScheme": "myapp", - "iosUniversalLink": "https://example.com/app/product/123", - "androidAppLink": "https://example.com/app/product/123", - "deepLinkPath": "/product/123", - "deepLinkParameters": { "ref": "campaign-1" }, - "utmParameters": { - "source": "twitter", - "medium": "social", - "campaign": "summer-sale" - }, - "ogTitle": "Check out this deal", - "ogDescription": "50% off summer sale", - "ogImageUrl": "https://example.com/og-image.png", - "targetingRules": { - "countries": ["US", "CA"], - "devices": ["ios", "android"], - "languages": ["en"] - }, - "attributionWindowHours": 168, - "customCode": "summer-sale", - "expiresAt": "2026-12-31T23:59:59Z" -} -``` - -All fields except `originalUrl` are optional. - -Install attribution settings, per link: - -- `attributionWindowHours` (1–2160, default 168): how long after a click an install can still be matched to it. -- `attributionWindowMinutes` (5–129600): the same window in minutes, for windows under an hour. Send one or the other; when set it replaces the hours, and updating the link with `attributionWindowHours` clears it. -- `oneInstallPerClick` (default `false`): a click is matched to one install only. A second install that would have matched the same click is matched to another click, or recorded as organic. Suited to single-use links, such as sign-in links. - -#### Get All Links - -```bash -# Single-tenant (all links) -GET /api/links - -# Multi-tenant (scoped to user) -GET /api/links?userId=user-uuid -``` - -#### Get a Specific Link - -```bash -GET /api/links/:id -GET /api/links/:id?userId=user-uuid -``` - -#### Update a Link - -```bash -PUT /api/links/:id?userId=user-uuid -Content-Type: application/json - -{ - "title": "Updated Title", - "isActive": false -} -``` - -#### Duplicate a Link - -```bash -POST /api/links/:id/duplicate?userId=user-uuid -``` - -#### Delete a Link - -```bash -DELETE /api/links/:id?userId=user-uuid -``` - -### Analytics - -#### Get Analytics Overview - -```bash -# All links -GET /api/analytics/overview?days=30 - -# Scoped to user -GET /api/analytics/overview?userId=user-uuid&days=30 -``` - -Returns: `totalClicks`, `uniqueClicks`, `clicksByDate`, `clicksByCountry`, `clicksByDevice`, `clicksByPlatform`, `topLinks` - -#### Get Link-Specific Analytics - -```bash -GET /api/analytics/links/:linkId?days=30 -``` - -### Redirect - -```bash -GET /:shortCode -GET /:templateSlug/:shortCode -``` - -Redirects users to the appropriate URL based on device type (iOS/Android/web), evaluates targeting rules, and tracks the click asynchronously. See [Redirects](docs/redirects.md) for the not-found page, the mobile interstitial and the Launchpad page. - -### QR Codes - -```bash -GET /api/links/:id/qr?format=png&size=300 -GET /api/links/:id/qr?format=svg -GET /api/links/:id/qr?url=https://links.example.com/spring/abc123 # encode the link on a custom host / template path -``` - -Without `url` the code encodes `SHORTLINK_DOMAIN/{shortCode}`. `url` must be an http(s) URL whose path ends in the link's short code; anything else is a 400. The Launchpad page uses it to encode its own address, so a phone that scans the code lands on the same host and path the visitor is on. - -### Webhooks - -```bash -GET /api/webhooks?userId=user-uuid -POST /api/webhooks # Body: { name, url, events, userId? } -GET /api/webhooks/:id?userId=user-uuid -PUT /api/webhooks/:id?userId=user-uuid -DELETE /api/webhooks/:id?userId=user-uuid -POST /api/webhooks/:id/test?userId=user-uuid -``` - -Events: `click_event`, `install_event`, `conversion_event`, `sdk_event`. Payloads are HMAC SHA-256 signed. - -Which webhooks hear about a link: one with an `organization_id` receives events for every link in that organization; one without receives events for links created by its `user_id`; one with neither receives events for links that have neither. To keep a delivery log, pass a function to `setWebhookDeliveryLogger()`. It is called with the webhook id, the attempt's result and the payload sent. - -### Mobile SDK Endpoints - -```bash -POST /api/sdk/v1/install # Report app install, get deferred deep link -GET /api/sdk/v1/attribution/:fingerprint # Debug attribution lookups -POST /api/sdk/v1/event # Track in-app conversion events -GET /api/sdk/v1/resolve/:shortCode # Resolve link to deep link data (no redirect) -GET /api/sdk/v1/health # Health check -``` - -An event whose `installId` this server no longer has is **recovered, not -refused**: the install is recorded again (marked `recovered`, with no link, -click or confidence score, so it is never mistaken for a fresh attributed -install) and the event is stored against it. - -This matters because the id lives in the app's storage for the life of the -install while the server's copy may not: a restore from an older backup, a -manual cleanup, an app build pointed at a fresh database, or an analytics -retention job all leave a live device holding an id nothing resolves. The -bundled SDKs store the id once and never re-register, so without recovery -that device is refused forever with no way back. The 404 that remains carries -`code: "INSTALL_NOT_FOUND"` and `action: "reregister"`. - -### Health - -```bash -GET /health # Liveness — process is up (no DB access) -GET /health/ready # Readiness — 503 if the database is unreachable -``` - -### Debug & Testing - -```bash -POST /api/debug/simulate # Simulate a link click with custom parameters -WS /api/debug/live?userId=user-uuid # WebSocket live click event stream -GET /api/debug/user-agents # Common UA strings for testing -GET /api/debug/countries # Common countries list -GET /api/debug/languages # Common languages list -``` - -### Well-Known Routes - -```bash -GET /.well-known/apple-app-site-association # iOS Universal Links -GET /.well-known/assetlinks.json # Android App Links -``` - -### OG Preview - -```bash -GET /:shortCode/preview # OG meta tag page for social scrapers -``` - ## Reference +- [API](docs/api.md) - [Redirects](docs/redirects.md) - [Configuration](docs/configuration.md): server options and hooks, `linkforty.config.json`, reverse proxies, environment variables - [Database schema](docs/database-schema.md) diff --git a/docs/README.md b/docs/README.md index 8f246f0..d94a224 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,8 @@ # LinkForty Core reference -Reference for running and embedding Core. These pages live with the code, so the copy in a release's git tag matches that release. For guides and the API, see [docs.linkforty.com](https://docs.linkforty.com). +Reference for running and embedding Core. These pages live with the code, so the copy in a release's git tag matches that release. For guides, see [docs.linkforty.com](https://docs.linkforty.com). +- [API](api.md): links, templates, analytics, QR codes, webhooks, SDK endpoints, health and debug routes - [Redirects](redirects.md): how a request is routed, the not-found page, the mobile interstitial and the Launchpad page - [Configuration](configuration.md): server options and hooks, `linkforty.config.json`, reverse proxies, email scanner detection and environment variables - [Database schema](database-schema.md): tables, and how short codes resolve diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..82a46ea --- /dev/null +++ b/docs/api.md @@ -0,0 +1,211 @@ +# API + +## Links + +### Create a Link + +`userId` is optional. When provided, the link is scoped to that user (multi-tenant mode). When omitted, the link has no owner (single-tenant mode). + +```bash +POST /api/links +Content-Type: application/json + +{ + "userId": "user-uuid", + "originalUrl": "https://example.com", + "title": "My Link", + "description": "Summer campaign link", + "iosAppStoreUrl": "https://apps.apple.com/app/id123456", + "androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.example", + "webFallbackUrl": "https://example.com/product/123", + "appScheme": "myapp", + "iosUniversalLink": "https://example.com/app/product/123", + "androidAppLink": "https://example.com/app/product/123", + "deepLinkPath": "/product/123", + "deepLinkParameters": { "ref": "campaign-1" }, + "utmParameters": { + "source": "twitter", + "medium": "social", + "campaign": "summer-sale" + }, + "ogTitle": "Check out this deal", + "ogDescription": "50% off summer sale", + "ogImageUrl": "https://example.com/og-image.png", + "targetingRules": { + "countries": ["US", "CA"], + "devices": ["ios", "android"], + "languages": ["en"] + }, + "attributionWindowHours": 168, + "customCode": "summer-sale", + "expiresAt": "2026-12-31T23:59:59Z" +} +``` + +All fields except `originalUrl` are optional. + +Install attribution settings, per link: + +- `attributionWindowHours` (1–2160, default 168): how long after a click an install can still be matched to it. +- `attributionWindowMinutes` (5–129600): the same window in minutes, for windows under an hour. Send one or the other; when set it replaces the hours, and updating the link with `attributionWindowHours` clears it. +- `oneInstallPerClick` (default `false`): a click is matched to one install only. A second install that would have matched the same click is matched to another click, or recorded as organic. Suited to single-use links, such as sign-in links. + +### Get All Links + +```bash +# Single-tenant (all links) +GET /api/links + +# Multi-tenant (scoped to user) +GET /api/links?userId=user-uuid +``` + +### Get a Specific Link + +```bash +GET /api/links/:id +GET /api/links/:id?userId=user-uuid +``` + +### Update a Link + +```bash +PUT /api/links/:id?userId=user-uuid +Content-Type: application/json + +{ + "title": "Updated Title", + "isActive": false +} +``` + +### Duplicate a Link + +```bash +POST /api/links/:id/duplicate?userId=user-uuid +``` + +### Delete a Link + +```bash +DELETE /api/links/:id?userId=user-uuid +``` + +## Templates + +```bash +GET /api/templates?userId=user-uuid +GET /api/templates/:id?userId=user-uuid +POST /api/templates # Body: { name, description?, settings, userId? } +PUT /api/templates/:id?userId=user-uuid +DELETE /api/templates/:id?userId=user-uuid +PUT /api/templates/:id/set-default?userId=user-uuid +``` + +## Analytics + +### Get Analytics Overview + +```bash +# All links +GET /api/analytics/overview?days=30 + +# Scoped to user +GET /api/analytics/overview?userId=user-uuid&days=30 +``` + +Returns: `totalClicks`, `uniqueClicks`, `clicksByDate`, `clicksByCountry`, `clicksByDevice`, `clicksByPlatform`, `topLinks` + +### Get Link-Specific Analytics + +```bash +GET /api/analytics/links/:linkId?days=30 +``` + +## Redirect + +```bash +GET /:shortCode +GET /:templateSlug/:shortCode +``` + +Redirects users to the appropriate URL based on device type (iOS/Android/web), evaluates targeting rules, and tracks the click asynchronously. See [Redirects](redirects.md) for the not-found page, the mobile interstitial and the Launchpad page. + +## QR Codes + +```bash +GET /api/links/:id/qr?format=png&size=300 +GET /api/links/:id/qr?format=svg +GET /api/links/:id/qr?url=https://links.example.com/spring/abc123 # encode the link on a custom host / template path +``` + +Without `url` the code encodes `SHORTLINK_DOMAIN/{shortCode}`. `url` must be an http(s) URL whose path ends in the link's short code; anything else is a 400. The Launchpad page uses it to encode its own address, so a phone that scans the code lands on the same host and path the visitor is on. + +## Webhooks + +```bash +GET /api/webhooks?userId=user-uuid +POST /api/webhooks # Body: { name, url, events, userId? } +GET /api/webhooks/:id?userId=user-uuid +PUT /api/webhooks/:id?userId=user-uuid +DELETE /api/webhooks/:id?userId=user-uuid +POST /api/webhooks/:id/test?userId=user-uuid +``` + +Events: `click_event`, `install_event`, `conversion_event`, `sdk_event`. Payloads are HMAC SHA-256 signed. + +Which webhooks hear about a link: one with an `organization_id` receives events for every link in that organization; one without receives events for links created by its `user_id`; one with neither receives events for links that have neither. To keep a delivery log, pass a function to `setWebhookDeliveryLogger()`. It is called with the webhook id, the attempt's result and the payload sent. + +## Mobile SDK Endpoints + +```bash +POST /api/sdk/v1/install # Report app install, get deferred deep link +GET /api/sdk/v1/attribution/:fingerprint # Debug attribution lookups +POST /api/sdk/v1/event # Track in-app conversion events +GET /api/sdk/v1/resolve/:shortCode # Resolve link to deep link data (no redirect) +GET /api/sdk/v1/resolve/:templateSlug/:shortCode +GET /api/sdk/v1/health # Health check +``` + +An event whose `installId` this server no longer has is **recovered, not +refused**: the install is recorded again (marked `recovered`, with no link, +click or confidence score, so it is never mistaken for a fresh attributed +install) and the event is stored against it. + +This matters because the id lives in the app's storage for the life of the +install while the server's copy may not: a restore from an older backup, a +manual cleanup, an app build pointed at a fresh database, or an analytics +retention job all leave a live device holding an id nothing resolves. The +bundled SDKs store the id once and never re-register, so without recovery +that device is refused forever with no way back. The 404 that remains carries +`code: "INSTALL_NOT_FOUND"` and `action: "reregister"`. + +## Health + +```bash +GET /health # Liveness — process is up (no DB access) +GET /health/ready # Readiness — 503 if the database is unreachable +``` + +## Debug & Testing + +```bash +POST /api/debug/simulate # Simulate a link click with custom parameters +WS /api/debug/live?userId=user-uuid # WebSocket live click event stream +GET /api/debug/user-agents # Common UA strings for testing +GET /api/debug/countries # Common countries list +GET /api/debug/languages # Common languages list +``` + +## Well-Known Routes + +```bash +GET /.well-known/apple-app-site-association # iOS Universal Links +GET /.well-known/assetlinks.json # Android App Links +``` + +## OG Preview + +```bash +GET /:shortCode/preview # OG meta tag page for social scrapers +``` From 6854a7a14398fde6037c406c7557d265ff9f069e Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Thu, 5 Nov 2026 09:43:10 -0800 Subject: [PATCH 2/2] fix: correct documented environment variables Replace SERVICE_DOMAIN with SHORTLINK_DOMAIN, the variable Core reads for QR code URLs, and show its value with the scheme, because the value is used as the start of the URL. Remove JWT_SECRET and the SMTP settings, which Core does not read, and document TRUST_EDGE_BOT_HEADER. Pass SHORTLINK_DOMAIN and TRUST_EDGE_BOT_HEADER through docker-compose.yml so that setting them in .env reaches the container. --- .env.example | 5 +++-- .github/SECURITY.md | 1 - DOCKER.md | 1 - docker-compose.yml | 13 +++++-------- docs/configuration.md | 8 ++++++-- llms.txt | 2 +- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index ffe035f..ed6b079 100644 --- a/.env.example +++ b/.env.example @@ -53,9 +53,10 @@ LINKFORTY_PORT=3000 CORS_ORIGIN=* # ----------------------------------------------------------------------------- -# Custom Domain Configuration (Optional) +# Short Link Domain (Optional) # ----------------------------------------------------------------------------- -# SERVICE_DOMAIN=yourdomain.com +# Base URL encoded in QR codes, including the scheme. Defaults to the request's host. +# SHORTLINK_DOMAIN=https://go.yourdomain.com # ----------------------------------------------------------------------------- # Mobile SDK Configuration (Optional - for iOS Universal Links and Android App Links) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index eae3a58..d3527c3 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -87,7 +87,6 @@ The following environment variables should be kept secure: - `DATABASE_URL` - Contains database credentials - `REDIS_URL` - Contains Redis credentials (if used) -- `JWT_SECRET` - Used for token signing - Any custom API keys or secrets ### Production Deployment diff --git a/DOCKER.md b/DOCKER.md index 3573280..d524e6e 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -55,7 +55,6 @@ docker run -d \ | `REDIS_URL` | No | - | Redis connection string (recommended) | | `PORT` | No | `3000` | Server port | | `NODE_ENV` | No | `production` | Environment mode | -| `JWT_SECRET` | No | - | JWT signing secret | | `ASN_DB_PATH` | No | - | ASN database (`.mmdb`) path, such as DB-IP's IP to ASN Lite; turns on experimental email scanner detection (mount the file into the container) | See [.env.example](.env.example) for complete configuration options. diff --git a/docker-compose.yml b/docker-compose.yml index e31e559..04f1937 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,15 +63,12 @@ services: TRUST_PROXY: ${TRUST_PROXY:-} TRUSTED_CLIENT_IP_HEADER: ${TRUSTED_CLIENT_IP_HEADER:-} - # Optional: JWT secret for authentication - # JWT_SECRET: ${JWT_SECRET} + # Optional: base URL encoded in QR codes, including the scheme. + SHORTLINK_DOMAIN: ${SHORTLINK_DOMAIN:-} - # Optional: Email configuration - # SMTP_HOST: ${SMTP_HOST} - # SMTP_PORT: ${SMTP_PORT} - # SMTP_USER: ${SMTP_USER} - # SMTP_PASS: ${SMTP_PASS} - # EMAIL_FROM: ${EMAIL_FROM} + # Optional: trust the x-lf-bot header. Only behind a proxy that sets it + # and strips copies sent by clients. + TRUST_EDGE_BOT_HEADER: ${TRUST_EDGE_BOT_HEADER:-} ports: - "${LINKFORTY_PORT:-3000}:3000" diff --git a/docs/configuration.md b/docs/configuration.md index 26ce681..bb21b74 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -177,6 +177,10 @@ IOS_BUNDLE_ID=com.yourcompany.yourapp ANDROID_PACKAGE_NAME=com.yourcompany.yourapp ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:... -# Custom domain for QR code URLs (optional) -SHORTLINK_DOMAIN=yourdomain.com +# Base URL encoded in QR codes, including the scheme (optional; defaults to the request's host) +SHORTLINK_DOMAIN=https://go.yourdomain.com + +# Trust the x-lf-bot header (1 or true) to mark a click as a bot (optional). +# Only behind a proxy that sets this header and strips copies sent by clients. +# TRUST_EDGE_BOT_HEADER=true ``` diff --git a/llms.txt b/llms.txt index b9dbc3f..f41eff0 100644 --- a/llms.txt +++ b/llms.txt @@ -135,7 +135,7 @@ HOST=0.0.0.0 NODE_ENV=production CORS_ORIGIN=* -# Short link domain (used in QR codes and SDK responses) +# Short link domain (used in QR codes) SHORTLINK_DOMAIN=https://go.yourdomain.com # iOS Universal Links (optional)