@linkforty/core 1.5.1 → 1.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/database.d.ts.map +1 -1
- package/dist/lib/database.js +30 -0
- package/dist/lib/database.js.map +1 -1
- package/dist/lib/fingerprint.js +4 -4
- package/dist/lib/fingerprint.js.map +1 -1
- package/dist/routes/debug.js +6 -6
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/links.d.ts.map +1 -1
- package/dist/routes/links.js +19 -10
- package/dist/routes/links.js.map +1 -1
- package/dist/routes/sdk.js +4 -4
- package/dist/routes/sdk.js.map +1 -1
- package/dist/routes/templates.d.ts +3 -0
- package/dist/routes/templates.d.ts.map +1 -0
- package/dist/routes/templates.js +261 -0
- package/dist/routes/templates.js.map +1 -0
- package/dist/types/index.d.ts +31 -0
- package/dist/types/index.d.ts.map +1 -1
- package/llms.txt +763 -0
- package/package.json +16 -3
package/llms.txt
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
# @linkforty/core
|
|
2
|
+
|
|
3
|
+
> 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.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @linkforty/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires:
|
|
12
|
+
- Node.js 20+
|
|
13
|
+
- PostgreSQL 14+
|
|
14
|
+
- Redis (optional, recommended for caching)
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import { createServer } from '@linkforty/core';
|
|
20
|
+
|
|
21
|
+
const server = await createServer({
|
|
22
|
+
database: {
|
|
23
|
+
url: 'postgresql://postgres:password@localhost:5432/linkforty',
|
|
24
|
+
pool: { min: 2, max: 10 },
|
|
25
|
+
},
|
|
26
|
+
redis: {
|
|
27
|
+
url: 'redis://localhost:6379', // optional
|
|
28
|
+
},
|
|
29
|
+
cors: {
|
|
30
|
+
origin: ['https://yourdomain.com'],
|
|
31
|
+
},
|
|
32
|
+
logger: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await server.listen({ port: 3000, host: '0.0.0.0' });
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
That's it. The server auto-creates all database tables on first startup, registers all API routes, and starts serving.
|
|
39
|
+
|
|
40
|
+
## Docker Quick Start
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
# docker-compose.yml
|
|
44
|
+
services:
|
|
45
|
+
postgres:
|
|
46
|
+
image: postgres:15
|
|
47
|
+
environment:
|
|
48
|
+
POSTGRES_DB: linkforty
|
|
49
|
+
POSTGRES_USER: linkforty
|
|
50
|
+
POSTGRES_PASSWORD: changeme
|
|
51
|
+
ports:
|
|
52
|
+
- "5432:5432"
|
|
53
|
+
volumes:
|
|
54
|
+
- pgdata:/var/lib/postgresql/data
|
|
55
|
+
|
|
56
|
+
redis:
|
|
57
|
+
image: redis:7-alpine
|
|
58
|
+
ports:
|
|
59
|
+
- "6379:6379"
|
|
60
|
+
|
|
61
|
+
linkforty:
|
|
62
|
+
image: node:20-alpine
|
|
63
|
+
working_dir: /app
|
|
64
|
+
command: node dist/index.js
|
|
65
|
+
ports:
|
|
66
|
+
- "3000:3000"
|
|
67
|
+
environment:
|
|
68
|
+
DATABASE_URL: postgresql://linkforty:changeme@postgres:5432/linkforty
|
|
69
|
+
REDIS_URL: redis://redis:6379
|
|
70
|
+
PORT: 3000
|
|
71
|
+
NODE_ENV: production
|
|
72
|
+
depends_on:
|
|
73
|
+
- postgres
|
|
74
|
+
- redis
|
|
75
|
+
|
|
76
|
+
volumes:
|
|
77
|
+
pgdata:
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
docker compose up -d
|
|
82
|
+
curl http://localhost:3000/health
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
### createServer(options)
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
interface ServerOptions {
|
|
91
|
+
database?: {
|
|
92
|
+
url?: string; // PostgreSQL connection string
|
|
93
|
+
pool?: {
|
|
94
|
+
min?: number; // Default: 2
|
|
95
|
+
max?: number; // Default: 10
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
redis?: {
|
|
99
|
+
url: string; // Redis connection string
|
|
100
|
+
};
|
|
101
|
+
cors?: {
|
|
102
|
+
origin: string | string[];
|
|
103
|
+
};
|
|
104
|
+
logger?: boolean; // Enable Fastify logger
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Environment Variables
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# Database (PostgreSQL)
|
|
112
|
+
DATABASE_URL=postgresql://linkforty:changeme@localhost:5432/linkforty
|
|
113
|
+
|
|
114
|
+
# Redis (optional — falls back to database on miss or error)
|
|
115
|
+
REDIS_URL=redis://localhost:6379
|
|
116
|
+
|
|
117
|
+
# Server
|
|
118
|
+
PORT=3000
|
|
119
|
+
HOST=0.0.0.0
|
|
120
|
+
NODE_ENV=production
|
|
121
|
+
CORS_ORIGIN=*
|
|
122
|
+
|
|
123
|
+
# Short link domain (used in QR codes and SDK responses)
|
|
124
|
+
SHORTLINK_DOMAIN=https://go.yourdomain.com
|
|
125
|
+
|
|
126
|
+
# iOS Universal Links (optional)
|
|
127
|
+
IOS_TEAM_ID=ABC123XYZ
|
|
128
|
+
IOS_BUNDLE_ID=com.yourcompany.yourapp
|
|
129
|
+
|
|
130
|
+
# Android App Links (optional)
|
|
131
|
+
ANDROID_PACKAGE_NAME=com.yourcompany.yourapp
|
|
132
|
+
ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:...
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Module Exports
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
import { createServer } from '@linkforty/core'; // Server factory
|
|
139
|
+
import { generateShortCode } from '@linkforty/core/utils'; // Utility functions
|
|
140
|
+
import { db } from '@linkforty/core/database'; // PostgreSQL pool
|
|
141
|
+
import { linkRoutes } from '@linkforty/core/routes'; // Fastify route plugins
|
|
142
|
+
import type { Link, AnalyticsData } from '@linkforty/core/types'; // TypeScript types
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## TypeScript Types
|
|
146
|
+
|
|
147
|
+
### Link
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
interface Link {
|
|
151
|
+
id: string; // UUID
|
|
152
|
+
userId?: string; // Optional (multi-tenant scoping)
|
|
153
|
+
template_id?: string;
|
|
154
|
+
template_slug?: string;
|
|
155
|
+
short_code: string; // Unique, immutable after creation
|
|
156
|
+
original_url: string;
|
|
157
|
+
title?: string;
|
|
158
|
+
description?: string;
|
|
159
|
+
|
|
160
|
+
// Platform-specific URLs
|
|
161
|
+
ios_app_store_url?: string;
|
|
162
|
+
android_app_store_url?: string;
|
|
163
|
+
web_fallback_url?: string;
|
|
164
|
+
|
|
165
|
+
// Deep linking
|
|
166
|
+
app_scheme?: string; // URI scheme (e.g., "myapp")
|
|
167
|
+
ios_universal_link?: string;
|
|
168
|
+
android_app_link?: string;
|
|
169
|
+
deep_link_path?: string; // In-app destination (e.g., "/product/123")
|
|
170
|
+
deep_link_parameters?: Record<string, any>;
|
|
171
|
+
|
|
172
|
+
// Analytics
|
|
173
|
+
utmParameters?: UTMParameters;
|
|
174
|
+
targeting_rules?: TargetingRules;
|
|
175
|
+
|
|
176
|
+
// Social preview (Open Graph)
|
|
177
|
+
og_title?: string;
|
|
178
|
+
og_description?: string;
|
|
179
|
+
og_image_url?: string;
|
|
180
|
+
og_type?: string;
|
|
181
|
+
|
|
182
|
+
// Lifecycle
|
|
183
|
+
attribution_window_hours?: number; // Default: 168 (7 days)
|
|
184
|
+
is_active: boolean;
|
|
185
|
+
expires_at?: string;
|
|
186
|
+
created_at: string;
|
|
187
|
+
updated_at: string;
|
|
188
|
+
click_count?: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface UTMParameters {
|
|
192
|
+
source?: string;
|
|
193
|
+
medium?: string;
|
|
194
|
+
campaign?: string;
|
|
195
|
+
term?: string;
|
|
196
|
+
content?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface TargetingRules {
|
|
200
|
+
countries?: string[]; // ISO country codes (e.g., ["US", "GB"])
|
|
201
|
+
devices?: ('ios' | 'android' | 'web')[];
|
|
202
|
+
languages?: string[]; // BCP 47 codes (e.g., ["en", "es"])
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Create/Update Link Request
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
interface CreateLinkRequest {
|
|
210
|
+
userId?: string;
|
|
211
|
+
templateId?: string;
|
|
212
|
+
originalUrl: string; // Required, must be valid URL
|
|
213
|
+
title?: string;
|
|
214
|
+
description?: string;
|
|
215
|
+
iosAppStoreUrl?: string;
|
|
216
|
+
androidAppStoreUrl?: string;
|
|
217
|
+
webFallbackUrl?: string;
|
|
218
|
+
appScheme?: string;
|
|
219
|
+
iosUniversalLink?: string;
|
|
220
|
+
androidAppLink?: string;
|
|
221
|
+
deepLinkPath?: string;
|
|
222
|
+
deepLinkParameters?: Record<string, any>;
|
|
223
|
+
utmParameters?: UTMParameters;
|
|
224
|
+
targetingRules?: TargetingRules;
|
|
225
|
+
ogTitle?: string;
|
|
226
|
+
ogDescription?: string;
|
|
227
|
+
ogImageUrl?: string;
|
|
228
|
+
ogType?: string;
|
|
229
|
+
attributionWindowHours?: number; // 1-2160
|
|
230
|
+
customCode?: string; // Custom short code (auto-generated if omitted)
|
|
231
|
+
expiresAt?: string; // ISO 8601 datetime
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// UpdateLinkRequest is Partial<CreateLinkRequest> plus:
|
|
235
|
+
interface UpdateLinkRequest extends Partial<CreateLinkRequest> {
|
|
236
|
+
isActive?: boolean;
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Link Template
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
interface LinkTemplate {
|
|
244
|
+
id: string;
|
|
245
|
+
userId?: string;
|
|
246
|
+
name: string;
|
|
247
|
+
slug: string; // Auto-generated 8-char alphanumeric
|
|
248
|
+
description?: string;
|
|
249
|
+
settings: LinkTemplateSettings;
|
|
250
|
+
is_default: boolean;
|
|
251
|
+
created_at: string;
|
|
252
|
+
updated_at: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface LinkTemplateSettings {
|
|
256
|
+
defaultIosUrl?: string;
|
|
257
|
+
defaultAndroidUrl?: string;
|
|
258
|
+
defaultWebFallbackUrl?: string;
|
|
259
|
+
defaultAttributionWindowHours?: number;
|
|
260
|
+
utmParameters?: UTMParameters;
|
|
261
|
+
targetingRules?: TargetingRules;
|
|
262
|
+
expiresAfterDays?: number;
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Analytics
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
interface AnalyticsData {
|
|
270
|
+
totalClicks: number;
|
|
271
|
+
uniqueClicks: number;
|
|
272
|
+
clicksByDate: Array<{ date: string; clicks: number }>;
|
|
273
|
+
clicksByCountry: Array<{ country: string; countryCode: string; clicks: number }>;
|
|
274
|
+
clicksByDevice: Array<{ device: string; clicks: number }>;
|
|
275
|
+
clicksByPlatform: Array<{ platform: string; clicks: number }>;
|
|
276
|
+
clicksByBrowser: Array<{ browser: string; clicks: number }>;
|
|
277
|
+
clicksByHour: Array<{ hour: number; clicks: number }>;
|
|
278
|
+
clicksByUtmSource: Array<{ source: string; clicks: number }>;
|
|
279
|
+
clicksByUtmMedium: Array<{ medium: string; clicks: number }>;
|
|
280
|
+
clicksByUtmCampaign: Array<{ campaign: string; clicks: number }>;
|
|
281
|
+
clicksByReferrer: Array<{ source: string; clicks: number }>;
|
|
282
|
+
topLinks: Array<{
|
|
283
|
+
id: string;
|
|
284
|
+
shortCode: string;
|
|
285
|
+
title: string | null;
|
|
286
|
+
originalUrl: string;
|
|
287
|
+
totalClicks: number;
|
|
288
|
+
uniqueClicks: number;
|
|
289
|
+
}>;
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Webhook
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
type WebhookEvent = 'click_event' | 'install_event' | 'conversion_event';
|
|
297
|
+
|
|
298
|
+
interface Webhook {
|
|
299
|
+
id: string;
|
|
300
|
+
user_id?: string;
|
|
301
|
+
name: string;
|
|
302
|
+
url: string;
|
|
303
|
+
secret: string; // Auto-generated, HMAC-SHA256 signing key
|
|
304
|
+
events: WebhookEvent[];
|
|
305
|
+
is_active: boolean;
|
|
306
|
+
retry_count: number; // 1-10
|
|
307
|
+
timeout_ms: number; // 1000-60000
|
|
308
|
+
headers: Record<string, string>;
|
|
309
|
+
created_at: string;
|
|
310
|
+
updated_at: string;
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## API Endpoints
|
|
315
|
+
|
|
316
|
+
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).
|
|
317
|
+
|
|
318
|
+
### Link Management
|
|
319
|
+
|
|
320
|
+
#### POST /api/links — Create Link
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
curl -X POST http://localhost:3000/api/links \
|
|
324
|
+
-H "Content-Type: application/json" \
|
|
325
|
+
-d '{
|
|
326
|
+
"originalUrl": "https://example.com/product/789",
|
|
327
|
+
"title": "Summer Campaign",
|
|
328
|
+
"iosAppStoreUrl": "https://apps.apple.com/app/id123456789",
|
|
329
|
+
"androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.example.app",
|
|
330
|
+
"webFallbackUrl": "https://example.com/product/789",
|
|
331
|
+
"deepLinkParameters": { "route": "product", "productId": "789" },
|
|
332
|
+
"utmParameters": { "source": "instagram", "medium": "social", "campaign": "summer" },
|
|
333
|
+
"templateId": "550e8400-e29b-41d4-a716-446655440000"
|
|
334
|
+
}'
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Response (201):
|
|
338
|
+
|
|
339
|
+
```json
|
|
340
|
+
{
|
|
341
|
+
"id": "uuid",
|
|
342
|
+
"shortCode": "abc12345",
|
|
343
|
+
"originalUrl": "https://example.com/product/789",
|
|
344
|
+
"title": "Summer Campaign",
|
|
345
|
+
"deepLinkParameters": { "route": "product", "productId": "789" },
|
|
346
|
+
"utmParameters": { "source": "instagram", "medium": "social", "campaign": "summer" },
|
|
347
|
+
"isActive": true,
|
|
348
|
+
"clickCount": 0,
|
|
349
|
+
"createdAt": "2025-01-15T10:30:00Z",
|
|
350
|
+
"updatedAt": "2025-01-15T10:30:00Z"
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
#### GET /api/links — List Links
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
curl "http://localhost:3000/api/links?userId=optional-user-id"
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
#### GET /api/links/:id — Get Link
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
curl "http://localhost:3000/api/links/uuid"
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
#### PUT /api/links/:id — Update Link
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
curl -X PUT "http://localhost:3000/api/links/uuid" \
|
|
370
|
+
-H "Content-Type: application/json" \
|
|
371
|
+
-d '{ "title": "Updated Title", "isActive": false }'
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
#### DELETE /api/links/:id — Delete Link
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
curl -X DELETE "http://localhost:3000/api/links/uuid"
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Response: `{ "success": true }`
|
|
381
|
+
|
|
382
|
+
#### POST /api/links/:id/duplicate — Clone Link
|
|
383
|
+
|
|
384
|
+
```bash
|
|
385
|
+
curl -X POST "http://localhost:3000/api/links/uuid/duplicate"
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Templates
|
|
389
|
+
|
|
390
|
+
#### POST /api/templates — Create Template
|
|
391
|
+
|
|
392
|
+
```bash
|
|
393
|
+
curl -X POST http://localhost:3000/api/templates \
|
|
394
|
+
-H "Content-Type: application/json" \
|
|
395
|
+
-d '{
|
|
396
|
+
"name": "E-commerce Links",
|
|
397
|
+
"settings": {
|
|
398
|
+
"defaultIosUrl": "https://apps.apple.com/app/id123",
|
|
399
|
+
"defaultAndroidUrl": "https://play.google.com/store/apps/details?id=com.example",
|
|
400
|
+
"defaultWebFallbackUrl": "https://example.com",
|
|
401
|
+
"defaultAttributionWindowHours": 168,
|
|
402
|
+
"utmParameters": { "source": "app", "medium": "deeplink" }
|
|
403
|
+
},
|
|
404
|
+
"isDefault": true
|
|
405
|
+
}'
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
#### GET /api/templates — List Templates
|
|
409
|
+
#### GET /api/templates/:id — Get Template
|
|
410
|
+
#### PUT /api/templates/:id — Update Template
|
|
411
|
+
#### DELETE /api/templates/:id — Delete Template
|
|
412
|
+
#### PUT /api/templates/:id/set-default — Set Default Template
|
|
413
|
+
|
|
414
|
+
### Analytics
|
|
415
|
+
|
|
416
|
+
#### GET /api/analytics/overview — Aggregate Analytics
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
curl "http://localhost:3000/api/analytics/overview?days=30"
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Returns full `AnalyticsData` object (see types above).
|
|
423
|
+
|
|
424
|
+
#### GET /api/analytics/links/:linkId — Link-Specific Analytics
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
curl "http://localhost:3000/api/analytics/links/uuid?days=7"
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### Webhooks
|
|
431
|
+
|
|
432
|
+
#### POST /api/webhooks — Create Webhook
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
curl -X POST http://localhost:3000/api/webhooks \
|
|
436
|
+
-H "Content-Type: application/json" \
|
|
437
|
+
-d '{
|
|
438
|
+
"name": "My Webhook",
|
|
439
|
+
"url": "https://example.com/webhooks/linkforty",
|
|
440
|
+
"events": ["click_event", "install_event", "conversion_event"],
|
|
441
|
+
"retryCount": 3,
|
|
442
|
+
"timeoutMs": 10000
|
|
443
|
+
}'
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Response includes auto-generated `secret` for HMAC verification.
|
|
447
|
+
|
|
448
|
+
#### GET /api/webhooks — List Webhooks
|
|
449
|
+
#### GET /api/webhooks/:id — Get Webhook (includes secret)
|
|
450
|
+
#### PUT /api/webhooks/:id — Update Webhook
|
|
451
|
+
#### DELETE /api/webhooks/:id — Delete Webhook
|
|
452
|
+
#### POST /api/webhooks/:id/test — Send Test Payload
|
|
453
|
+
|
|
454
|
+
**Webhook signature verification:**
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
import crypto from 'crypto';
|
|
458
|
+
|
|
459
|
+
function verifyWebhook(body: string, signature: string, secret: string): boolean {
|
|
460
|
+
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
|
|
461
|
+
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(`sha256=${expected}`));
|
|
462
|
+
}
|
|
463
|
+
// Signature header: X-LinkForty-Signature
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Redirects (Public)
|
|
467
|
+
|
|
468
|
+
#### GET /:shortCode — Follow Short Link
|
|
469
|
+
|
|
470
|
+
Redirects (302) based on device:
|
|
471
|
+
1. iOS device → `ios_app_store_url`
|
|
472
|
+
2. Android device → `android_app_store_url`
|
|
473
|
+
3. Desktop/other → `web_fallback_url` → `original_url`
|
|
474
|
+
|
|
475
|
+
UTM parameters are appended to the redirect URL. Click is tracked asynchronously.
|
|
476
|
+
|
|
477
|
+
#### GET /:templateSlug/:shortCode — Template-Based Redirect
|
|
478
|
+
|
|
479
|
+
Same behavior, resolves via template slug + short code.
|
|
480
|
+
|
|
481
|
+
### QR Codes
|
|
482
|
+
|
|
483
|
+
#### GET /api/links/:id/qr — Generate QR Code
|
|
484
|
+
|
|
485
|
+
```bash
|
|
486
|
+
# PNG (default)
|
|
487
|
+
curl "http://localhost:3000/api/links/uuid/qr" -o qr.png
|
|
488
|
+
|
|
489
|
+
# SVG
|
|
490
|
+
curl "http://localhost:3000/api/links/uuid/qr?format=svg" -o qr.svg
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### Mobile SDK Endpoints (Public)
|
|
494
|
+
|
|
495
|
+
These are called by the LinkForty mobile SDKs (`@linkforty/mobile-sdk-react-native`, `@linkforty/mobile-sdk-expo`, iOS SDK, Android SDK).
|
|
496
|
+
|
|
497
|
+
#### POST /api/sdk/v1/install — Report App Install
|
|
498
|
+
|
|
499
|
+
```bash
|
|
500
|
+
curl -X POST http://localhost:3000/api/sdk/v1/install \
|
|
501
|
+
-H "Content-Type: application/json" \
|
|
502
|
+
-d '{
|
|
503
|
+
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
|
|
504
|
+
"timezone": "America/New_York",
|
|
505
|
+
"language": "en-US",
|
|
506
|
+
"screenWidth": 390,
|
|
507
|
+
"screenHeight": 844,
|
|
508
|
+
"platform": "iOS",
|
|
509
|
+
"platformVersion": "17.0",
|
|
510
|
+
"attributionWindowHours": 168
|
|
511
|
+
}'
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Response:
|
|
515
|
+
|
|
516
|
+
```json
|
|
517
|
+
{
|
|
518
|
+
"installId": "uuid",
|
|
519
|
+
"attributed": true,
|
|
520
|
+
"confidenceScore": 85,
|
|
521
|
+
"matchedFactors": ["ip", "user_agent", "timezone", "language", "screen"],
|
|
522
|
+
"deepLinkData": {
|
|
523
|
+
"shortCode": "abc123",
|
|
524
|
+
"originalUrl": "https://example.com/product/789",
|
|
525
|
+
"iosUrl": "https://apps.apple.com/app/id123",
|
|
526
|
+
"androidUrl": "https://play.google.com/store/apps/details?id=com.example",
|
|
527
|
+
"webFallbackUrl": "https://example.com/product/789",
|
|
528
|
+
"utmParameters": { "source": "instagram" },
|
|
529
|
+
"deepLinkParameters": { "route": "product", "productId": "789" }
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
#### GET /api/sdk/v1/resolve/:shortCode — Resolve Deep Link Data
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
curl "http://localhost:3000/api/sdk/v1/resolve/abc123?fp_tz=America/New_York&fp_platform=ios"
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Response:
|
|
541
|
+
|
|
542
|
+
```json
|
|
543
|
+
{
|
|
544
|
+
"shortCode": "abc123",
|
|
545
|
+
"linkId": "uuid",
|
|
546
|
+
"deepLinkPath": "/product/789",
|
|
547
|
+
"appScheme": "myapp",
|
|
548
|
+
"iosUrl": "https://apps.apple.com/app/id123",
|
|
549
|
+
"androidUrl": "https://play.google.com/store/apps/details?id=com.example",
|
|
550
|
+
"webUrl": "https://example.com/product/789",
|
|
551
|
+
"utmParameters": { "source": "instagram" },
|
|
552
|
+
"customParameters": { "route": "product", "productId": "789" },
|
|
553
|
+
"clickedAt": "2025-01-15T10:30:00Z"
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
#### POST /api/sdk/v1/event — Track In-App Event
|
|
558
|
+
|
|
559
|
+
```bash
|
|
560
|
+
curl -X POST http://localhost:3000/api/sdk/v1/event \
|
|
561
|
+
-H "Content-Type: application/json" \
|
|
562
|
+
-d '{
|
|
563
|
+
"installId": "uuid",
|
|
564
|
+
"eventName": "purchase",
|
|
565
|
+
"eventData": { "amount": 29.99, "currency": "USD" }
|
|
566
|
+
}'
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Response: `{ "eventId": "uuid", "acknowledged": true }`
|
|
570
|
+
|
|
571
|
+
### Well-Known (Auto-Served)
|
|
572
|
+
|
|
573
|
+
#### GET /.well-known/apple-app-site-association
|
|
574
|
+
|
|
575
|
+
Serves the AASA file for iOS Universal Links. Configure via `IOS_TEAM_ID` and `IOS_BUNDLE_ID` env vars.
|
|
576
|
+
|
|
577
|
+
#### GET /.well-known/assetlinks.json
|
|
578
|
+
|
|
579
|
+
Serves Digital Asset Links for Android App Links. Configure via `ANDROID_PACKAGE_NAME` and `ANDROID_SHA256_FINGERPRINTS` env vars.
|
|
580
|
+
|
|
581
|
+
## Redirect Flow
|
|
582
|
+
|
|
583
|
+
When a user clicks a short link (e.g., `https://go.yourdomain.com/abc123`):
|
|
584
|
+
|
|
585
|
+
1. Check Redis cache (`link:{shortCode}`, 5-min TTL)
|
|
586
|
+
2. Fall back to PostgreSQL if cache miss or Redis unavailable
|
|
587
|
+
3. Evaluate targeting rules (countries, devices, languages) — return 404 if no match
|
|
588
|
+
4. Track click asynchronously (does not block redirect)
|
|
589
|
+
5. Select destination URL based on device (iOS → Android → web → original)
|
|
590
|
+
6. Append UTM parameters to destination URL
|
|
591
|
+
7. Return 302 redirect
|
|
592
|
+
|
|
593
|
+
## Fingerprint Attribution
|
|
594
|
+
|
|
595
|
+
When a mobile SDK reports an install, Core matches it to a previous click using probabilistic fingerprinting:
|
|
596
|
+
|
|
597
|
+
| Factor | Weight |
|
|
598
|
+
|--------|--------|
|
|
599
|
+
| IP address | 40 points |
|
|
600
|
+
| User agent | 30 points |
|
|
601
|
+
| Timezone | 10 points |
|
|
602
|
+
| Language | 10 points |
|
|
603
|
+
| Screen resolution | 10 points |
|
|
604
|
+
|
|
605
|
+
A match requires 70+ confidence score out of 100. Default attribution window is 168 hours (7 days), configurable per link (1-2160 hours).
|
|
606
|
+
|
|
607
|
+
## Database Schema
|
|
608
|
+
|
|
609
|
+
Tables are auto-created on first startup by `initializeDatabase()`. No separate migration step needed.
|
|
610
|
+
|
|
611
|
+
| Table | Purpose |
|
|
612
|
+
|-------|---------|
|
|
613
|
+
| `link_templates` | Reusable link configuration templates |
|
|
614
|
+
| `links` | Short links with all configuration |
|
|
615
|
+
| `click_events` | Click analytics with geolocation |
|
|
616
|
+
| `device_fingerprints` | Fingerprint components for attribution matching |
|
|
617
|
+
| `install_events` | Mobile app install tracking |
|
|
618
|
+
| `in_app_events` | Conversion events from mobile apps |
|
|
619
|
+
| `webhooks` | Webhook endpoint configurations |
|
|
620
|
+
|
|
621
|
+
All tables use UUID primary keys (`gen_random_uuid()`), `created_at`/`updated_at` timestamps, and snake_case column names.
|
|
622
|
+
|
|
623
|
+
## Extending Core
|
|
624
|
+
|
|
625
|
+
Core returns a standard Fastify instance, so you can add custom routes, plugins, and hooks:
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
import { createServer } from '@linkforty/core';
|
|
629
|
+
|
|
630
|
+
const server = await createServer({
|
|
631
|
+
database: { url: process.env.DATABASE_URL },
|
|
632
|
+
logger: true,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// Add custom authentication
|
|
636
|
+
server.addHook('onRequest', async (request, reply) => {
|
|
637
|
+
const apiKey = request.headers['x-api-key'];
|
|
638
|
+
if (!apiKey || apiKey !== process.env.API_KEY) {
|
|
639
|
+
reply.code(401).send({ error: 'Unauthorized' });
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Add custom routes
|
|
644
|
+
server.get('/api/custom/stats', async (request, reply) => {
|
|
645
|
+
const { db } = await import('@linkforty/core/database');
|
|
646
|
+
const result = await db.query('SELECT COUNT(*) FROM links');
|
|
647
|
+
return { totalLinks: parseInt(result.rows[0].count) };
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
await server.listen({ port: 3000, host: '0.0.0.0' });
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
## Complete Self-Hosted Server Example
|
|
654
|
+
|
|
655
|
+
```typescript
|
|
656
|
+
// server.ts
|
|
657
|
+
import 'dotenv/config';
|
|
658
|
+
import { createServer } from '@linkforty/core';
|
|
659
|
+
|
|
660
|
+
async function start() {
|
|
661
|
+
const server = await createServer({
|
|
662
|
+
database: {
|
|
663
|
+
url: process.env.DATABASE_URL || 'postgresql://linkforty:changeme@localhost:5432/linkforty',
|
|
664
|
+
pool: { min: 2, max: 10 },
|
|
665
|
+
},
|
|
666
|
+
redis: process.env.REDIS_URL
|
|
667
|
+
? { url: process.env.REDIS_URL }
|
|
668
|
+
: undefined,
|
|
669
|
+
cors: {
|
|
670
|
+
origin: process.env.CORS_ORIGIN?.split(',') || ['*'],
|
|
671
|
+
},
|
|
672
|
+
logger: true,
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
const port = parseInt(process.env.PORT || '3000');
|
|
676
|
+
const host = process.env.HOST || '0.0.0.0';
|
|
677
|
+
|
|
678
|
+
await server.listen({ port, host });
|
|
679
|
+
console.log(`LinkForty Core running at http://${host}:${port}`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
start().catch((err) => {
|
|
683
|
+
console.error('Failed to start:', err);
|
|
684
|
+
process.exit(1);
|
|
685
|
+
});
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
```json
|
|
689
|
+
// package.json
|
|
690
|
+
{
|
|
691
|
+
"type": "module",
|
|
692
|
+
"scripts": {
|
|
693
|
+
"start": "node --loader tsx server.ts",
|
|
694
|
+
"dev": "tsx watch server.ts"
|
|
695
|
+
},
|
|
696
|
+
"dependencies": {
|
|
697
|
+
"@linkforty/core": "^1.6.0",
|
|
698
|
+
"dotenv": "^16.3.0"
|
|
699
|
+
},
|
|
700
|
+
"devDependencies": {
|
|
701
|
+
"tsx": "^4.0.0",
|
|
702
|
+
"typescript": "^5.2.0"
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
## Utility Functions
|
|
708
|
+
|
|
709
|
+
```typescript
|
|
710
|
+
import {
|
|
711
|
+
generateShortCode,
|
|
712
|
+
parseUserAgent,
|
|
713
|
+
getLocationFromIP,
|
|
714
|
+
buildRedirectUrl,
|
|
715
|
+
detectDevice,
|
|
716
|
+
} from '@linkforty/core/utils';
|
|
717
|
+
|
|
718
|
+
generateShortCode(8);
|
|
719
|
+
// → "aB3kX9mQ" (nanoid-based, configurable length)
|
|
720
|
+
|
|
721
|
+
parseUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0...');
|
|
722
|
+
// → { deviceType: "mobile", platform: "iOS", platformVersion: "17.0", browser: "Safari" }
|
|
723
|
+
|
|
724
|
+
getLocationFromIP('8.8.8.8');
|
|
725
|
+
// → { countryCode: "US", countryName: "United States", city: "Mountain View", ... }
|
|
726
|
+
|
|
727
|
+
detectDevice('Mozilla/5.0 (iPhone...)');
|
|
728
|
+
// → "ios"
|
|
729
|
+
|
|
730
|
+
buildRedirectUrl('https://example.com', { source: 'email', medium: 'campaign' });
|
|
731
|
+
// → "https://example.com?utm_source=email&utm_medium=campaign"
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
## Error Responses
|
|
735
|
+
|
|
736
|
+
All errors follow this format:
|
|
737
|
+
|
|
738
|
+
```json
|
|
739
|
+
{
|
|
740
|
+
"error": "Error message",
|
|
741
|
+
"statusCode": 400
|
|
742
|
+
}
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
| Status | Meaning |
|
|
746
|
+
|--------|---------|
|
|
747
|
+
| 400 | Validation error (bad request body) |
|
|
748
|
+
| 404 | Link/resource not found, or targeting rules excluded user |
|
|
749
|
+
| 500 | Internal server error |
|
|
750
|
+
|
|
751
|
+
## Real-Time Events
|
|
752
|
+
|
|
753
|
+
Core emits click events via an internal event emitter and optional WebSocket:
|
|
754
|
+
|
|
755
|
+
```typescript
|
|
756
|
+
import { subscribeToClickEvents } from '@linkforty/core';
|
|
757
|
+
|
|
758
|
+
const unsubscribe = subscribeToClickEvents((event) => {
|
|
759
|
+
console.log('Click:', event.shortCode, event.deviceType, event.country);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// WebSocket endpoint: ws://localhost:3000/api/debug/live
|
|
763
|
+
```
|