# @linkforty/core

> Open-source deeplink management engine built on Fastify + PostgreSQL + optional Redis. Provides smart link routing with device detection, click analytics, UTM tracking, deferred deep linking, device fingerprinting, webhooks, QR codes, link templates, and mobile SDK endpoints. No auth included — bring your own. Install via npm, connect to PostgreSQL, and you have a full deep linking platform. Licensed AGPL-3.0-only.

## Installation

```bash
npm install @linkforty/core
```

Requires:
- Node.js 20+
- PostgreSQL 14+
- Redis (optional, recommended for caching)

## Quick Start

```typescript
import { createServer } from '@linkforty/core';

const server = await createServer({
  database: {
    url: 'postgresql://postgres:password@localhost:5432/linkforty',
    pool: { min: 2, max: 10 },
  },
  redis: {
    url: 'redis://localhost:6379', // optional
  },
  cors: {
    origin: ['https://yourdomain.com'],
  },
  logger: true,
});

await server.listen({ port: 3000, host: '0.0.0.0' });
```

That's it. The server auto-creates all database tables on first startup, registers all API routes, and starts serving.

## Docker Quick Start

```yaml
# docker-compose.yml
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_DB: linkforty
      POSTGRES_USER: linkforty
      POSTGRES_PASSWORD: changeme
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  linkforty:
    image: node:20-alpine
    working_dir: /app
    command: node dist/index.js
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://linkforty:changeme@postgres:5432/linkforty
      REDIS_URL: redis://redis:6379
      PORT: 3000
      NODE_ENV: production
    depends_on:
      - postgres
      - redis

volumes:
  pgdata:
```

```bash
docker compose up -d
curl http://localhost:3000/health
```

## Configuration

### createServer(options)

```typescript
interface ServerOptions {
  database?: {
    url?: string;        // PostgreSQL connection string
    pool?: {
      min?: number;      // Default: 2
      max?: number;      // Default: 10
    };
  };
  redis?: {
    url: string;         // Redis connection string
  };
  cors?: {
    origin: string | string[];
  };
  logger?: boolean;      // Enable Fastify logger
}
```

### Environment Variables

```bash
# Database (PostgreSQL)
DATABASE_URL=postgresql://linkforty:changeme@localhost:5432/linkforty

# Redis (optional — falls back to database on miss or error)
REDIS_URL=redis://localhost:6379

# Server
PORT=3000
HOST=0.0.0.0
NODE_ENV=production
CORS_ORIGIN=*

# Short link domain (used in QR codes and SDK responses)
SHORTLINK_DOMAIN=https://go.yourdomain.com

# iOS Universal Links (optional)
IOS_TEAM_ID=ABC123XYZ
IOS_BUNDLE_ID=com.yourcompany.yourapp

# Android App Links (optional)
ANDROID_PACKAGE_NAME=com.yourcompany.yourapp
ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:...
```

## Module Exports

```typescript
import { createServer } from '@linkforty/core';              // Server factory
import { generateShortCode } from '@linkforty/core/utils';   // Utility functions
import { db } from '@linkforty/core/database';               // PostgreSQL pool
import { linkRoutes } from '@linkforty/core/routes';         // Fastify route plugins
import type { Link, AnalyticsData } from '@linkforty/core/types'; // TypeScript types
```

## TypeScript Types

### Link

```typescript
interface Link {
  id: string;                          // UUID
  userId?: string;                     // Optional (multi-tenant scoping)
  template_id?: string;
  template_slug?: string;
  short_code: string;                  // Unique, immutable after creation
  original_url: string;
  title?: string;
  description?: string;

  // Platform-specific URLs
  ios_app_store_url?: string;
  android_app_store_url?: string;
  web_fallback_url?: string;

  // Deep linking
  app_scheme?: string;                 // URI scheme (e.g., "myapp")
  ios_universal_link?: string;
  android_app_link?: string;
  deep_link_path?: string;             // In-app destination (e.g., "/product/123")
  deep_link_parameters?: Record<string, any>;

  // Analytics
  utmParameters?: UTMParameters;
  targeting_rules?: TargetingRules;

  // Social preview (Open Graph)
  og_title?: string;
  og_description?: string;
  og_image_url?: string;
  og_type?: string;

  // Lifecycle
  attribution_window_hours?: number;   // Default: 168 (7 days)
  is_active: boolean;
  expires_at?: string;
  created_at: string;
  updated_at: string;
  click_count?: number;
}

interface UTMParameters {
  source?: string;
  medium?: string;
  campaign?: string;
  term?: string;
  content?: string;
}

interface TargetingRules {
  countries?: string[];                // ISO country codes (e.g., ["US", "GB"])
  devices?: ('ios' | 'android' | 'web')[];
  languages?: string[];                // BCP 47 codes (e.g., ["en", "es"])
}
```

### Create/Update Link Request

```typescript
interface CreateLinkRequest {
  userId?: string;
  templateId?: string;
  originalUrl: string;                 // Required, must be valid URL
  title?: string;
  description?: string;
  iosAppStoreUrl?: string;
  androidAppStoreUrl?: string;
  webFallbackUrl?: string;
  appScheme?: string;
  iosUniversalLink?: string;
  androidAppLink?: string;
  deepLinkPath?: string;
  deepLinkParameters?: Record<string, any>;
  utmParameters?: UTMParameters;
  targetingRules?: TargetingRules;
  ogTitle?: string;
  ogDescription?: string;
  ogImageUrl?: string;
  ogType?: string;
  attributionWindowHours?: number;     // 1-2160
  customCode?: string;                 // Custom short code (auto-generated if omitted)
  expiresAt?: string;                  // ISO 8601 datetime
}

// UpdateLinkRequest is Partial<CreateLinkRequest> plus:
interface UpdateLinkRequest extends Partial<CreateLinkRequest> {
  isActive?: boolean;
}
```

### Link Template

```typescript
interface LinkTemplate {
  id: string;
  userId?: string;
  name: string;
  slug: string;                        // Auto-generated 8-char alphanumeric
  description?: string;
  settings: LinkTemplateSettings;
  is_default: boolean;
  created_at: string;
  updated_at: string;
}

interface LinkTemplateSettings {
  defaultIosUrl?: string;
  defaultAndroidUrl?: string;
  defaultWebFallbackUrl?: string;
  defaultAttributionWindowHours?: number;
  utmParameters?: UTMParameters;
  targetingRules?: TargetingRules;
  expiresAfterDays?: number;
}
```

### Analytics

```typescript
interface AnalyticsData {
  totalClicks: number;
  uniqueClicks: number;
  clicksByDate: Array<{ date: string; clicks: number }>;
  clicksByCountry: Array<{ country: string; countryCode: string; clicks: number }>;
  clicksByDevice: Array<{ device: string; clicks: number }>;
  clicksByPlatform: Array<{ platform: string; clicks: number }>;
  clicksByBrowser: Array<{ browser: string; clicks: number }>;
  clicksByHour: Array<{ hour: number; clicks: number }>;
  clicksByUtmSource: Array<{ source: string; clicks: number }>;
  clicksByUtmMedium: Array<{ medium: string; clicks: number }>;
  clicksByUtmCampaign: Array<{ campaign: string; clicks: number }>;
  clicksByReferrer: Array<{ source: string; clicks: number }>;
  topLinks: Array<{
    id: string;
    shortCode: string;
    title: string | null;
    originalUrl: string;
    totalClicks: number;
    uniqueClicks: number;
  }>;
}
```

### Webhook

```typescript
type WebhookEvent = 'click_event' | 'install_event' | 'conversion_event';

interface Webhook {
  id: string;
  user_id?: string;
  name: string;
  url: string;
  secret: string;                      // Auto-generated, HMAC-SHA256 signing key
  events: WebhookEvent[];
  is_active: boolean;
  retry_count: number;                 // 1-10
  timeout_ms: number;                  // 1000-60000
  headers: Record<string, string>;
  created_at: string;
  updated_at: string;
}
```

## API Endpoints

All endpoints accept JSON request/response bodies. The `userId` query parameter is optional on all routes — when provided, queries are scoped to that user (multi-tenant mode). When omitted, all records are accessible (single-tenant mode).

### Link Management

#### POST /api/links — Create Link

```bash
curl -X POST http://localhost:3000/api/links \
  -H "Content-Type: application/json" \
  -d '{
    "originalUrl": "https://example.com/product/789",
    "title": "Summer Campaign",
    "iosAppStoreUrl": "https://apps.apple.com/app/id123456789",
    "androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.example.app",
    "webFallbackUrl": "https://example.com/product/789",
    "deepLinkParameters": { "route": "product", "productId": "789" },
    "utmParameters": { "source": "instagram", "medium": "social", "campaign": "summer" },
    "templateId": "550e8400-e29b-41d4-a716-446655440000"
  }'
```

Response (201):

```json
{
  "id": "uuid",
  "shortCode": "abc12345",
  "originalUrl": "https://example.com/product/789",
  "title": "Summer Campaign",
  "deepLinkParameters": { "route": "product", "productId": "789" },
  "utmParameters": { "source": "instagram", "medium": "social", "campaign": "summer" },
  "isActive": true,
  "clickCount": 0,
  "createdAt": "2025-01-15T10:30:00Z",
  "updatedAt": "2025-01-15T10:30:00Z"
}
```

#### GET /api/links — List Links

```bash
curl "http://localhost:3000/api/links?userId=optional-user-id"
```

#### GET /api/links/:id — Get Link

```bash
curl "http://localhost:3000/api/links/uuid"
```

#### PUT /api/links/:id — Update Link

```bash
curl -X PUT "http://localhost:3000/api/links/uuid" \
  -H "Content-Type: application/json" \
  -d '{ "title": "Updated Title", "isActive": false }'
```

#### DELETE /api/links/:id — Delete Link

```bash
curl -X DELETE "http://localhost:3000/api/links/uuid"
```

Response: `{ "success": true }`

#### POST /api/links/:id/duplicate — Clone Link

```bash
curl -X POST "http://localhost:3000/api/links/uuid/duplicate"
```

### Templates

#### POST /api/templates — Create Template

```bash
curl -X POST http://localhost:3000/api/templates \
  -H "Content-Type: application/json" \
  -d '{
    "name": "E-commerce Links",
    "settings": {
      "defaultIosUrl": "https://apps.apple.com/app/id123",
      "defaultAndroidUrl": "https://play.google.com/store/apps/details?id=com.example",
      "defaultWebFallbackUrl": "https://example.com",
      "defaultAttributionWindowHours": 168,
      "utmParameters": { "source": "app", "medium": "deeplink" }
    },
    "isDefault": true
  }'
```

#### GET /api/templates — List Templates
#### GET /api/templates/:id — Get Template
#### PUT /api/templates/:id — Update Template
#### DELETE /api/templates/:id — Delete Template
#### PUT /api/templates/:id/set-default — Set Default Template

### Analytics

#### GET /api/analytics/overview — Aggregate Analytics

```bash
curl "http://localhost:3000/api/analytics/overview?days=30"
```

Returns full `AnalyticsData` object (see types above).

#### GET /api/analytics/links/:linkId — Link-Specific Analytics

```bash
curl "http://localhost:3000/api/analytics/links/uuid?days=7"
```

### Webhooks

#### POST /api/webhooks — Create Webhook

```bash
curl -X POST http://localhost:3000/api/webhooks \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Webhook",
    "url": "https://example.com/webhooks/linkforty",
    "events": ["click_event", "install_event", "conversion_event"],
    "retryCount": 3,
    "timeoutMs": 10000
  }'
```

Response includes auto-generated `secret` for HMAC verification.

#### GET /api/webhooks — List Webhooks
#### GET /api/webhooks/:id — Get Webhook (includes secret)
#### PUT /api/webhooks/:id — Update Webhook
#### DELETE /api/webhooks/:id — Delete Webhook
#### POST /api/webhooks/:id/test — Send Test Payload

**Webhook signature verification:**

```typescript
import crypto from 'crypto';

function verifyWebhook(body: string, signature: string, secret: string): boolean {
  const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(`sha256=${expected}`));
}
// Signature header: X-LinkForty-Signature
```

### Redirects (Public)

#### GET /:shortCode — Follow Short Link

Redirects (302) based on device:
1. iOS device → `ios_app_store_url`
2. Android device → `android_app_store_url`
3. Desktop/other → `web_fallback_url` → `original_url`

UTM parameters are appended to the redirect URL. Click is tracked asynchronously.

#### GET /:templateSlug/:shortCode — Template-Based Redirect

Same behavior, resolves via template slug + short code.

### QR Codes

#### GET /api/links/:id/qr — Generate QR Code

```bash
# PNG (default)
curl "http://localhost:3000/api/links/uuid/qr" -o qr.png

# SVG
curl "http://localhost:3000/api/links/uuid/qr?format=svg" -o qr.svg
```

### Mobile SDK Endpoints (Public)

These are called by the LinkForty mobile SDKs (`@linkforty/mobile-sdk-react-native`, `@linkforty/mobile-sdk-expo`, iOS SDK, Android SDK).

#### POST /api/sdk/v1/install — Report App Install

```bash
curl -X POST http://localhost:3000/api/sdk/v1/install \
  -H "Content-Type: application/json" \
  -d '{
    "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
    "timezone": "America/New_York",
    "language": "en-US",
    "screenWidth": 390,
    "screenHeight": 844,
    "platform": "iOS",
    "platformVersion": "17.0",
    "attributionWindowHours": 168
  }'
```

Response:

```json
{
  "installId": "uuid",
  "attributed": true,
  "confidenceScore": 85,
  "matchedFactors": ["ip", "user_agent", "timezone", "language", "screen"],
  "deepLinkData": {
    "shortCode": "abc123",
    "originalUrl": "https://example.com/product/789",
    "iosUrl": "https://apps.apple.com/app/id123",
    "androidUrl": "https://play.google.com/store/apps/details?id=com.example",
    "webFallbackUrl": "https://example.com/product/789",
    "utmParameters": { "source": "instagram" },
    "deepLinkParameters": { "route": "product", "productId": "789" }
  }
}
```

#### GET /api/sdk/v1/resolve/:shortCode — Resolve Deep Link Data

```bash
curl "http://localhost:3000/api/sdk/v1/resolve/abc123?fp_tz=America/New_York&fp_platform=ios"
```

Response:

```json
{
  "shortCode": "abc123",
  "linkId": "uuid",
  "deepLinkPath": "/product/789",
  "appScheme": "myapp",
  "iosUrl": "https://apps.apple.com/app/id123",
  "androidUrl": "https://play.google.com/store/apps/details?id=com.example",
  "webUrl": "https://example.com/product/789",
  "utmParameters": { "source": "instagram" },
  "customParameters": { "route": "product", "productId": "789" },
  "clickedAt": "2025-01-15T10:30:00Z"
}
```

#### POST /api/sdk/v1/event — Track In-App Event

```bash
curl -X POST http://localhost:3000/api/sdk/v1/event \
  -H "Content-Type: application/json" \
  -d '{
    "installId": "uuid",
    "eventName": "purchase",
    "eventData": { "amount": 29.99, "currency": "USD" }
  }'
```

Response: `{ "eventId": "uuid", "acknowledged": true }`

### Well-Known (Auto-Served)

#### GET /.well-known/apple-app-site-association

Serves the AASA file for iOS Universal Links. Configure via `IOS_TEAM_ID` and `IOS_BUNDLE_ID` env vars.

#### GET /.well-known/assetlinks.json

Serves Digital Asset Links for Android App Links. Configure via `ANDROID_PACKAGE_NAME` and `ANDROID_SHA256_FINGERPRINTS` env vars.

## Redirect Flow

When a user clicks a short link (e.g., `https://go.yourdomain.com/abc123`):

1. Check Redis cache (`link:{shortCode}`, 5-min TTL)
2. Fall back to PostgreSQL if cache miss or Redis unavailable
3. Evaluate targeting rules (countries, devices, languages) — return 404 if no match
4. Track click asynchronously (does not block redirect)
5. Select destination URL based on device (iOS → Android → web → original)
6. Append UTM parameters to destination URL
7. Return 302 redirect

## Fingerprint Attribution

When a mobile SDK reports an install, Core matches it to a previous click using probabilistic fingerprinting:

| Factor | Weight |
|--------|--------|
| IP address | 40 points |
| User agent | 30 points |
| Timezone | 10 points |
| Language | 10 points |
| Screen resolution | 10 points |

A match requires 70+ confidence score out of 100. Default attribution window is 168 hours (7 days), configurable per link (1-2160 hours).

## Database Schema

Tables are auto-created on first startup by `initializeDatabase()`. No separate migration step needed.

| Table | Purpose |
|-------|---------|
| `link_templates` | Reusable link configuration templates |
| `links` | Short links with all configuration |
| `click_events` | Click analytics with geolocation |
| `device_fingerprints` | Fingerprint components for attribution matching |
| `install_events` | Mobile app install tracking |
| `in_app_events` | Conversion events from mobile apps |
| `webhooks` | Webhook endpoint configurations |

All tables use UUID primary keys (`gen_random_uuid()`), `created_at`/`updated_at` timestamps, and snake_case column names.

## Extending Core

Core returns a standard Fastify instance, so you can add custom routes, plugins, and hooks:

```typescript
import { createServer } from '@linkforty/core';

const server = await createServer({
  database: { url: process.env.DATABASE_URL },
  logger: true,
});

// Add custom authentication
server.addHook('onRequest', async (request, reply) => {
  const apiKey = request.headers['x-api-key'];
  if (!apiKey || apiKey !== process.env.API_KEY) {
    reply.code(401).send({ error: 'Unauthorized' });
  }
});

// Add custom routes
server.get('/api/custom/stats', async (request, reply) => {
  const { db } = await import('@linkforty/core/database');
  const result = await db.query('SELECT COUNT(*) FROM links');
  return { totalLinks: parseInt(result.rows[0].count) };
});

await server.listen({ port: 3000, host: '0.0.0.0' });
```

## Complete Self-Hosted Server Example

```typescript
// server.ts
import 'dotenv/config';
import { createServer } from '@linkforty/core';

async function start() {
  const server = await createServer({
    database: {
      url: process.env.DATABASE_URL || 'postgresql://linkforty:changeme@localhost:5432/linkforty',
      pool: { min: 2, max: 10 },
    },
    redis: process.env.REDIS_URL
      ? { url: process.env.REDIS_URL }
      : undefined,
    cors: {
      origin: process.env.CORS_ORIGIN?.split(',') || ['*'],
    },
    logger: true,
  });

  const port = parseInt(process.env.PORT || '3000');
  const host = process.env.HOST || '0.0.0.0';

  await server.listen({ port, host });
  console.log(`LinkForty Core running at http://${host}:${port}`);
}

start().catch((err) => {
  console.error('Failed to start:', err);
  process.exit(1);
});
```

```json
// package.json
{
  "type": "module",
  "scripts": {
    "start": "node --loader tsx server.ts",
    "dev": "tsx watch server.ts"
  },
  "dependencies": {
    "@linkforty/core": "^1.6.0",
    "dotenv": "^16.3.0"
  },
  "devDependencies": {
    "tsx": "^4.0.0",
    "typescript": "^5.2.0"
  }
}
```

## Utility Functions

```typescript
import {
  generateShortCode,
  parseUserAgent,
  getLocationFromIP,
  buildRedirectUrl,
  detectDevice,
} from '@linkforty/core/utils';

generateShortCode(8);
// → "aB3kX9mQ" (nanoid-based, configurable length)

parseUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0...');
// → { deviceType: "mobile", platform: "iOS", platformVersion: "17.0", browser: "Safari" }

getLocationFromIP('8.8.8.8');
// → { countryCode: "US", countryName: "United States", city: "Mountain View", ... }

detectDevice('Mozilla/5.0 (iPhone...)');
// → "ios"

buildRedirectUrl('https://example.com', { source: 'email', medium: 'campaign' });
// → "https://example.com?utm_source=email&utm_medium=campaign"
```

## Error Responses

All errors follow this format:

```json
{
  "error": "Error message",
  "statusCode": 400
}
```

| Status | Meaning |
|--------|---------|
| 400 | Validation error (bad request body) |
| 404 | Link/resource not found, or targeting rules excluded user |
| 500 | Internal server error |

## Real-Time Events

Core emits click events via an internal event emitter and optional WebSocket:

```typescript
import { subscribeToClickEvents } from '@linkforty/core';

const unsubscribe = subscribeToClickEvents((event) => {
  console.log('Click:', event.shortCode, event.deviceType, event.country);
});

// WebSocket endpoint: ws://localhost:3000/api/debug/live
```
