@linkforty/core 1.44.2 → 1.44.4

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.
Files changed (3) hide show
  1. package/README.md +20 -588
  2. package/llms.txt +1 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  **Open-source alternative to Branch.io, AppsFlyer OneLink, and Firebase Dynamic Links**
7
7
 
8
- Self-hosted deep linking engine with device detection, analytics, deferred deep linking, and smart routing. No per-click pricing, no vendor lock-in, full data ownership — runs on your own PostgreSQL. Firebase Dynamic Links shut down in August 2025; LinkForty is a production-ready, open-source replacement you can deploy today.
8
+ Self-hosted deep linking engine with device detection, analytics, deferred deep linking, and smart routing. No per-click pricing, no vendor lock-in, full data ownership — runs on your own PostgreSQL. Firebase Dynamic Links shut down in August 2025; LinkForty is a production-ready, open-source alternative you can deploy today.
9
9
  </div>
10
10
 
11
11
  [![npm version](https://img.shields.io/npm/v/@linkforty/core.svg)](https://www.npmjs.com/package/@linkforty/core)
@@ -21,25 +21,17 @@
21
21
  - **No per-click pricing** — No usage-based fees, no monthly minimums, no enterprise sales calls
22
22
  - **Full data ownership** — All click data, analytics, and attribution stored in your PostgreSQL database
23
23
  - **Privacy-first** — No third-party data sharing, no tracking pixels, your users' data stays with you
24
- - **Drop-in replacement** — REST API + mobile SDKs for React Native, Expo, iOS (Swift), and Android (Kotlin)
25
- - **Firebase Dynamic Links replacement** — Google shut down Firebase Dynamic Links in August 2025. LinkForty provides the same capabilities with a self-hosted, open-source stack
24
+ - **REST API and mobile SDKs** — SDKs for React Native, Expo, Flutter, iOS (Swift), and Android (Kotlin)
25
+ - **Firebase Dynamic Links alternative** — Google shut down Firebase Dynamic Links in August 2025. LinkForty is a self-hosted, open-source alternative
26
26
 
27
27
  ### How LinkForty Compares
28
28
 
29
- | Feature | LinkForty Core | Branch | AppsFlyer | Firebase Dynamic Links |
30
- |---------|---------------|--------|-----------|----------------------|
31
- | **Open Source** | Yes (AGPL-3.0) | No | No | No |
32
- | **Self-Hosted** | Yes | No | No | No |
33
- | **Data Ownership** | Complete | Vendor-controlled | Vendor-controlled | Was Google-controlled |
34
- | **Deferred Deep Linking** | Yes | Yes | Yes | Was supported |
35
- | **Device Detection & Routing** | Yes | Yes | Yes | Was supported |
36
- | **Click Analytics** | Yes | Yes | Yes | Basic |
37
- | **QR Code Generation** | Built-in | No | No | No |
38
- | **Webhooks** | Yes | Enterprise only | Enterprise only | No |
39
- | **iOS Universal Links** | Yes | Yes | Yes | Was supported |
40
- | **Android App Links** | Yes | Yes | Yes | Was supported |
41
- | **UTM Parameter Tracking** | Yes | Yes | Custom params | Was supported |
42
- | **Custom Domains** | Yes | Enterprise only | Enterprise only | No |
29
+ | | LinkForty Core | Branch | AppsFlyer |
30
+ |---|----------------|--------|-----------|
31
+ | **Open source** | Yes (AGPL-3.0) | No | No |
32
+ | **Self-hosted** | Yes | No | No |
33
+ | **Where your data lives** | Your PostgreSQL | Branch's servers | AppsFlyer's servers |
34
+ | **Per-click pricing** | None | Check their pricing | Check their pricing |
43
35
 
44
36
  ## Features
45
37
 
@@ -243,70 +235,7 @@ GET /:shortCode
243
235
  GET /:templateSlug/:shortCode
244
236
  ```
245
237
 
246
- Automatically redirects users to the appropriate URL based on device type (iOS/Android/web), evaluates targeting rules, and tracks the click asynchronously.
247
-
248
- **Not-found page:** When a link can't be served, a browser gets a small HTML page ("Link not found") with status 404, `noindex`, `Cache-Control: no-store` and a content-security policy that allows no script. That covers an unknown code, an expired or switched-off link, a link on another organization's host, a visitor outside the link's targeting rules, and a link with no destination for the visitor's device. All of these render the same page, so an expired link can't be told apart from a code that never existed. Clients that don't send `Accept: text/html` (API callers, scripts) keep the JSON body: `{ "error": "Link not found" }`, or `{ "error": "No destination URL configured for this link" }` for a link with no destination. A link withdrawn for abuse keeps its own 410 notice.
249
-
250
- You can change the page or send browsers somewhere else instead, without writing code. The first of these that is set wins:
251
-
252
- 1. **The organization's settings**, `organizations.settings.notFound`, on a host that belongs to the organization. On a shared host, only a live link that has nothing for this visitor (a targeting miss, no destination) uses its organization's settings. An unknown, expired or switched-off code there uses the instance's.
253
-
254
- ```json
255
- {
256
- "notFound": {
257
- "redirectUrl": "https://example.com", // send browsers here (302); the rest is ignored
258
- "title": "This link has expired",
259
- "message": "Open the app to continue.",
260
- "logoUrl": "https://cdn.example/logo.png",
261
- "accentColor": "#0f766e", // the button
262
- "backgroundColor": "#f6f7f8", // light mode only
263
- "buttonLabel": "Get the app", // shown only with buttonUrl
264
- "buttonUrl": "https://example.com/app"
265
- }
266
- }
267
- ```
268
-
269
- Changes show within a minute.
270
- 2. **The instance's defaults**: the `notFound` section of `linkforty.config.json` (see [Configuration file](#configuration-file)), with the same fields.
271
- 3. **The built-in page.**
272
-
273
- URLs must be http(s) and colors hex; anything else is dropped, and all text is escaped. The `notFound` option below decides in code ahead of all three.
274
-
275
- **Mobile interstitial:** When a link has `appScheme` configured and a store fallback URL (iOS App Store or Google Play), mobile requests receive a smart interstitial page instead of a raw 302 redirect. The interstitial tries to open the app via URI scheme and falls back to the app store after 1.5 seconds. This handles the case where a 302 to a custom URI scheme fails silently when the app is not installed. URL fragments are preserved through the redirect, enabling patterns like E2E encryption where the decryption key lives in the fragment.
276
-
277
- **Launchpad page (desktop):** A link to app-only content has nowhere to send a desktop visitor. When the chain resolves no web destination (link → template → workspace `appConfig`), a desktop request gets a hosted landing page instead of an error: the link's title, description and image (`ogTitle` / `ogDescription` / `ogImageUrl`, falling back to `title` / `description`), the app's icon and name, the official App Store / Google Play badges (embedded in the page, so it makes no third-party request), and a QR code so the visitor can finish on their phone. The page is `noindex`, sent with `Cache-Control: no-store` and a content-security policy that allows no script without a per-response nonce, and it never navigates on its own — no timers, no automatic scheme attempts.
278
-
279
- When the link does have a web destination, the page offers it as a "Continue on the web" link, and the title links there too, so a visitor is never trapped. By default it appears **only** where the alternative is nothing; a link that resolves to a destination still gets its 302. Two knobs change that:
280
-
281
- - `organizations.settings.launchpad` (per workspace):
282
-
283
- ```json
284
- {
285
- "launchpad": {
286
- "desktop": "no-destination", // "no-destination" (default) | "always" | "off"
287
- "mobile": "store", // "store" (default) | "page"
288
- "appName": "Example App",
289
- "appIconUrl": "https://cdn.example/icon.png",
290
- "appWebsiteUrl": "https://example.com", // optional; the header's icon and name link here
291
- "accentColor": "#0f766e", // buttons, the web link, focus rings
292
- "backgroundColor": "#101418" // optional; fixes the page's palette (see below)
293
- }
294
- }
295
- ```
296
-
297
- `appWebsiteUrl` is deliberately explicit rather than derived from a link's web fallback, which may be someone else's page (an event listing, a video). Unset, the header is not a link.
298
-
299
- Without `backgroundColor` the page follows the visitor's light/dark preference. With one, the background is fixed and the text, surfaces and borders are derived from it — a light or a dark brand color both stay legible, and the visitor's theme no longer changes the page.
300
-
301
- - The `launchpad` section of `linkforty.config.json` (per instance): the same fields except `templateId`, which names a host-rendered template and stays per workspace. It's the default for every link, including links with no workspace. A workspace's `settings.launchpad` overrides it **field by field**: a workspace that sets only `accentColor` keeps the instance's `appName`, icon and modes. (The not-found page works differently: a workspace's `settings.notFound` replaces the instance's as a whole.)
302
-
303
- - `links.launchpad_mode` (per link, `launchpadMode` on the links API): `inherit` (default), `on` or `off`. The link's value wins over the workspace's and the instance's.
304
-
305
- With `desktop: "off"` (or `launchpad_mode: "off"`), a link with no web destination gets the plain "This link opens in an app" page instead.
306
-
307
- **Launchpad page (mobile):** With `mobile: "page"`, a phone visitor whose platform has no Universal Link / App Link on the link gets the same page instead of the store redirect or the scheme interstitial: an "Open in app" button when the link has a URI scheme (a tap, not a navigation — the URL fragment is appended on tap), the visitor's own store button, and the web fallback as a "Continue on the web" link. That last one matters inside in-app browsers (Gmail, Facebook, Instagram…), which bypass Universal Links: the store redirect would send someone who *has* the app to the store, so the redirect path prefers the web fallback there to give the OS a second chance on the next hop — and the page keeps that hop available as a tap. Links that carry a Universal Link / App Link keep their 302 regardless, because the OS resolves the installed case before the request reaches this server. `launchpad_mode: "off"` opts a link out; `"on"` does not force the page onto a workspace that chose `"store"`. The default, `"store"`, is unchanged behaviour.
308
-
309
- Two hooks on `RedirectRouteOptions` let a host application extend the page without forking it — see [Server Options](#server-options).
238
+ 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.
310
239
 
311
240
  ### QR Codes
312
241
 
@@ -386,458 +315,17 @@ GET /.well-known/assetlinks.json # Android App Links
386
315
  GET /:shortCode/preview # OG meta tag page for social scrapers
387
316
  ```
388
317
 
389
- ## Configuration
390
-
391
- ### Server Options
392
-
393
- ```typescript
394
- interface ServerOptions {
395
- database?: {
396
- url?: string; // PostgreSQL connection string
397
- pool?: {
398
- min?: number; // Minimum pool connections (default: 2)
399
- max?: number; // Maximum pool connections (default: 10)
400
- };
401
- };
402
- redis?: {
403
- url: string; // Redis connection string (optional)
404
- };
405
- cors?: {
406
- origin: string | string[]; // CORS allowed origins (default: '*')
407
- };
408
- logger?: boolean; // Enable Fastify logger (default: true)
409
- trustProxy?: boolean | number; // Trust X-Forwarded-For when behind a proxy (default: false)
410
- redirect?: RedirectRouteOptions; // See below
411
- sdk?: SdkRouteOptions; // See below
412
- config?: LinkFortyConfig; // linkforty.config.json, from loadConfig()
413
- }
414
-
415
- interface RedirectRouteOptions {
416
- abuseReportUrl?: string; // Linked from the link-safety warning page
417
- launchpad?: {
418
- // Supply richer content for the launchpad page than the link row carries:
419
- // a rendered share image, a templated hero. Return null for the default.
420
- // A hook that throws is logged and treated as null — the page never fails
421
- // because of it. `heroHtml` is inserted unescaped: render it yourself,
422
- // never from end-user input.
423
- // `context.webUrl` is the link's web destination, decorated exactly as
424
- // "Continue on the web" uses it (or null), so a hero that draws its own
425
- // title can link it the way the default page does.
426
- resolveContent?: (link, settings, context: { webUrl: string | null }) => Promise<LaunchpadContent | null>;
427
- // Endpoint that receives `{ linkId, event }` beacons from the page —
428
- // "view" on load; "cta_ios" / "cta_android" / "cta_open" / "cta_web" /
429
- // "cta_app_website" on tap. No cookies or identifiers. Unset means the
430
- // page sends nothing. If you validate event names, accept all of these.
431
- beaconUrl?: string;
432
- };
433
- // Return true when the request is on a host reserved for web links. Every
434
- // link on it then sends every device to its web destination with a plain
435
- // 302: no launchpad page, no scheme attempt, no store. Don't serve
436
- // association files on that host, so no installed app can claim its URLs.
437
- // A hook that throws is logged and treated as false.
438
- isWebLinkRequest?: (request) => Promise<boolean> | boolean;
439
- // The instance's not-found page or redirect, used when the organization has
440
- // no settings.notFound of its own. createServer() fills it from the
441
- // `notFound` section of the config file (ServerOptions.config).
442
- notFoundDefaults?: NotFoundSettings;
443
- // Decide the not-found response in code, ahead of the organization's
444
- // settings and notFoundDefaults: page content (title,
445
- // message, logoUrl, accentColor, backgroundColor, buttonLabel, buttonUrl — all
446
- // optional, all escaped; non-http(s) URLs and non-hex colors are dropped),
447
- // a URL to 302 to (http(s) only), or null to fall through. Called only
448
- // for requests that accept HTML, and never for a link withdrawn for abuse.
449
- // `context.reason` is "unknown" | "disabled" | "not_targeted" |
450
- // "no_destination"; `context.link` is the link row (null for "unknown");
451
- // `context.scope` is the host's organization, if any. Use the reason to
452
- // decide whose settings apply, not to vary the page: an expired link must
453
- // look like a code that never existed. A hook that throws is logged and
454
- // treated as null.
455
- notFound?: (request, context: { reason, link, scope }) =>
456
- Promise<NotFoundResolution | null> | NotFoundResolution | null;
457
- }
458
-
459
- interface SdkRouteOptions {
460
- // Narrow the clicks an install can be attributed to. A server shared by
461
- // several apps resolves the installing app (from the SDK's `appToken`, say)
462
- // and returns its organization, so an install only ever matches — and
463
- // receives the deep link of — that organization's links. Return null to
464
- // match against every click, as when the option is absent. A hook that
465
- // throws is logged and treated as null.
466
- resolveInstallScope?: (request, body: { appToken?: string }) =>
467
- Promise<{ organizationId?: string } | null> | { organizationId?: string } | null;
468
- }
469
- ```
470
-
471
- Install matching only considers clicks from the installing device's own network (its IPv4 /24 or IPv6 /64), since no other click can reach the confidence threshold, so the attribution window holds however much traffic the server takes. It relies on the `idx_fingerprints_ip` index, which `initializeDatabase()` builds concurrently on first start so click writes are never blocked.
472
-
473
- `renderLaunchpadPage()`, `defaultLaunchpadContent()` and `shouldServeLaunchpadOnDesktop()` are exported so a host can render the same page elsewhere (a settings preview, for instance). `renderNotFoundPage()` is exported for the same reason, and for a host that answers some requests before they reach the redirect route.
474
-
475
- ### Configuration file
476
-
477
- Core splits its configuration the way most self-hosted platforms do:
478
-
479
- - **Environment variables** hold infrastructure and secrets: `DATABASE_URL`, `REDIS_URL`, `PORT`, `TRUST_PROXY`, and so on.
480
- - **`linkforty.config.json`** holds the instance's defaults and behavior, including text a visitor reads. Today that's the `notFound` and `launchpad` sections; see [linkforty.config.example.json](linkforty.config.example.json) and [DOCKER.md](DOCKER.md#configuration-file) for every setting.
481
- - **`organizations.settings`** holds per-organization values, which take precedence over the file where both apply.
482
-
483
- The file is optional. Core reads the path in `LINKFORTY_CONFIG`, or `linkforty.config.json` in the working directory. A file with an invalid value or unknown setting stops the server at startup with the setting named, rather than starting with it ignored. The Docker image does this for you. When you create the server yourself, load it and pass it in:
484
-
485
- ```typescript
486
- import { createServer, loadConfig } from '@linkforty/core';
487
-
488
- const { config } = loadConfig(); // throws ConfigError on a bad file
489
- const server = await createServer({ database: { url: process.env.DATABASE_URL }, config });
490
- ```
491
-
492
- Options passed in code (such as `redirect.notFoundDefaults`) take precedence over the file.
493
-
494
- ### Running behind a reverse proxy
495
-
496
- When Core runs behind a reverse proxy, CDN, or load balancer, set `trustProxy` so the server uses the real client IP from `X-Forwarded-For` for redirect targeting, geo, attribution, and fingerprinting. Pass it when creating the server (e.g. `trustProxy: true` or a number of proxy hops) or set the `TRUST_PROXY` environment variable (e.g. `TRUST_PROXY=1`). Client-provided `ipAddress` in the SDK install request body is **not** used as the trusted IP; it is optional debug metadata only and must not be relied on for attribution.
497
-
498
- ### Email security scanner detection
499
-
500
- Mail security services (Microsoft Defender Safe Links, Mimecast, Proofpoint and others) open every link in a delivered email to check where it leads. They send a normal desktop browser user agent, so without help those visits count as human clicks, and on links sent by email they can outnumber the people.
501
-
502
- Core can recognize them by the network they come from. Give it a MaxMind [GeoLite2-ASN](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data) database (free with a MaxMind account; the license doesn't allow shipping it with Core, and MaxMind updates it weekly):
503
-
504
- ```bash
505
- ASN_DB_PATH=/data/GeoLite2-ASN.mmdb
506
- ```
507
-
508
- or pass `botDetection: { asnDatabasePath }` to `createServer()`. A click is then recorded with `is_bot = true` and `bot_reason = 'email_scanner'` when it comes from a desktop browser on Microsoft, Amazon or Google's networks (ASNs 8075, 16509, 14618, 15169, 396982). Mobile browsers, app HTTP clients and SDK requests from those networks are not flagged. Without the database the rule is off; a file that can't be opened is logged and the server starts without it.
509
-
510
- To change the networks, set `botDetection.emailScannerAsns` in `linkforty.config.json`. It replaces the built-in list, and an empty list turns the rule off. Networks iCloud Private Relay exits through (Apple, Cloudflare, Akamai, Fastly) are rejected, since real Safari users would be flagged.
511
-
512
- A host that registers the routes itself instead of calling `createServer()` turns the rule on with `loadScannerDetection(path, { asns })`.
513
-
514
- ### Environment Variables
515
-
516
- ```bash
517
- DATABASE_URL=postgresql://localhost/linkforty
518
- REDIS_URL=redis://localhost:6379
519
- PORT=3000
520
- NODE_ENV=production
521
- CORS_ORIGIN=*
522
- # When behind a reverse proxy: TRUST_PROXY=1 (or number of hops) so client IP is read from X-Forwarded-For
523
- # TRUST_PROXY=1
524
- # Email security scanner detection (optional): path to a MaxMind GeoLite2-ASN database
525
- # ASN_DB_PATH=/data/GeoLite2-ASN.mmdb
526
-
527
- # Mobile SDK (optional — for iOS Universal Links and Android App Links)
528
- IOS_TEAM_ID=ABC123XYZ
529
- IOS_BUNDLE_ID=com.yourcompany.yourapp
530
- ANDROID_PACKAGE_NAME=com.yourcompany.yourapp
531
- ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:...
532
-
533
- # Custom domain for QR code URLs (optional)
534
- SHORTLINK_DOMAIN=yourdomain.com
535
- ```
536
-
537
- ## Database Schema
538
-
539
- Core does not create a `users` table. Authentication and user management are the consumer's responsibility. The `user_id` column on `links` and `webhooks` is optional (nullable, no foreign key) — use it for multi-tenant scoping when your auth layer provides a user identity.
540
-
541
- ### Short codes and URL resolution
542
-
543
- Every short code is unique across the server. Links created through Core's API have no organization, so they all share one set of codes.
544
-
545
- An application that builds on Core and assigns links to organizations (by setting `links.organization_id`) gets one set of codes per organization: two organizations can each have `promo`. Template slugs stay unique across the server, and URLs resolve like this:
546
-
547
- - `/{templateSlug}/{shortCode}` resolves to exactly one link.
548
- - `/{shortCode}` on a request that belongs to an organization resolves only that organization's link with that code.
549
- - A request that belongs to an organization is served only that organization's links, in either form: another organization's link is answered with 404, and the refusal is logged.
550
- - `/{shortCode}` on any other request prefers a link without a template, then the oldest link with that code.
551
-
552
- To tell Core which organization a request belongs to, for example by custom domain, register a resolver:
553
-
554
- ```typescript
555
- import { setLinkScopeResolver } from '@linkforty/core';
556
-
557
- setLinkScopeResolver(async (request) => organizationIdForHost(request.headers.host) ?? null);
558
- ```
559
-
560
- The redirect, SDK resolve and preview routes all use it. A resolver that throws is logged and treated as null.
561
-
562
- ### Links Table
563
-
564
- | Column | Type | Description |
565
- |-------------------------|--------------|------------------------------------------|
566
- | id | UUID | Primary key |
567
- | user_id | UUID | Optional owner/tenant identifier |
568
- | short_code | VARCHAR(20) | Short code, unique per organization_id |
569
- | original_url | TEXT | Original URL |
570
- | title | VARCHAR(255) | Link title |
571
- | description | TEXT | Link description |
572
- | ios_app_store_url | TEXT | iOS App Store URL |
573
- | android_app_store_url | TEXT | Android Play Store URL |
574
- | web_fallback_url | TEXT | Web fallback URL |
575
- | app_scheme | VARCHAR(255) | URI scheme (e.g., "myapp") |
576
- | ios_universal_link | TEXT | iOS Universal Link URL |
577
- | android_app_link | TEXT | Android App Link URL |
578
- | deep_link_path | TEXT | In-app destination path |
579
- | deep_link_parameters | JSONB | Custom app parameters |
580
- | utm_parameters | JSONB | UTM tracking parameters |
581
- | targeting_rules | JSONB | Country/device/language targeting |
582
- | og_title | VARCHAR(255) | Open Graph title |
583
- | og_description | TEXT | Open Graph description |
584
- | og_image_url | TEXT | Open Graph image URL |
585
- | og_type | VARCHAR(50) | Open Graph type (default: "website") |
586
- | launchpad_mode | VARCHAR(10) | Launchpad page: inherit / on / off |
587
- | attribution_window_hours| INTEGER | Install attribution window (default: 168)|
588
- | attribution_window_minutes| INTEGER | Window in minutes; replaces the hours when set |
589
- | one_install_per_click | BOOLEAN | A click matches one install only (default: false) |
590
- | is_active | BOOLEAN | Active status |
591
- | expires_at | TIMESTAMP | Expiration date |
592
- | created_at | TIMESTAMP | Creation timestamp |
593
- | updated_at | TIMESTAMP | Last update timestamp |
594
-
595
- ### Click Events Table
596
-
597
- | Column | Type | Description |
598
- |--------------|--------------|------------------------------|
599
- | id | UUID | Primary key |
600
- | link_id | UUID | Foreign key to links |
601
- | clicked_at | TIMESTAMP | Click timestamp |
602
- | ip_address | INET | User IP address |
603
- | user_agent | TEXT | User agent string |
604
- | device_type | VARCHAR(20) | Device type (ios/android/web)|
605
- | platform | VARCHAR(20) | Platform (iOS/Android/Web) |
606
- | country_code | CHAR(2) | Country code |
607
- | country_name | VARCHAR(100) | Country name |
608
- | region | VARCHAR(100) | Region/state |
609
- | city | VARCHAR(100) | City |
610
- | latitude | DECIMAL | Latitude |
611
- | longitude | DECIMAL | Longitude |
612
- | timezone | VARCHAR(100) | Timezone |
613
- | utm_source | VARCHAR(255) | UTM source |
614
- | utm_medium | VARCHAR(255) | UTM medium |
615
- | utm_campaign | VARCHAR(255) | UTM campaign |
616
- | referrer | TEXT | Referrer URL |
617
- | is_bot | BOOLEAN | Automated traffic, excluded from human analytics |
618
- | bot_reason | VARCHAR(16) | Why: `edge`, `method`, `ua` or `email_scanner` |
619
-
620
- ### Device Fingerprints Table
621
-
622
- | Column | Type | Description |
623
- |------------------|-------------|-------------------------------------|
624
- | id | UUID | Primary key |
625
- | click_id | UUID | Foreign key to click_events |
626
- | fingerprint_hash | VARCHAR(64) | SHA-256 hash of fingerprint signals |
627
- | ip_address | INET | IP address |
628
- | user_agent | TEXT | User agent string |
629
- | timezone | VARCHAR(100)| Timezone |
630
- | language | VARCHAR(10) | Browser language |
631
- | screen_width | INTEGER | Screen width |
632
- | screen_height | INTEGER | Screen height |
633
- | platform | VARCHAR(50) | Platform |
634
- | platform_version | VARCHAR(50) | Platform version |
635
- | created_at | TIMESTAMP | Creation timestamp |
636
-
637
- ### Install Events Table
638
-
639
- | Column | Type | Description |
640
- |-------------------------|-------------|------------------------------------|
641
- | id | UUID | Primary key |
642
- | link_id | UUID | Attributed link (nullable) |
643
- | click_id | UUID | Attributed click (nullable) |
644
- | fingerprint_hash | VARCHAR(64) | Device fingerprint hash |
645
- | confidence_score | DECIMAL | Match confidence (0-100) |
646
- | installed_at | TIMESTAMP | Install timestamp |
647
- | first_open_at | TIMESTAMP | First app open |
648
- | deep_link_retrieved | BOOLEAN | Whether deferred link was fetched |
649
- | deep_link_data | JSONB | Deferred deep link data |
650
- | attribution_window_hours| INTEGER | Attribution window used (default: 168) |
651
- | device_id | VARCHAR(255)| Optional device identifier |
652
- | created_at | TIMESTAMP | Creation timestamp |
653
-
654
- ### In-App Events Table
655
-
656
- | Column | Type | Description |
657
- |-----------------|--------------|------------------------------|
658
- | id | UUID | Primary key |
659
- | install_id | UUID | Foreign key to install_events|
660
- | event_name | VARCHAR(255) | Event name |
661
- | event_data | JSONB | Custom event properties |
662
- | event_timestamp | TIMESTAMP | When the event occurred |
663
- | created_at | TIMESTAMP | Creation timestamp |
664
-
665
- ### Webhooks Table
666
-
667
- | Column | Type | Description |
668
- |------------|--------------|----------------------------------|
669
- | id | UUID | Primary key |
670
- | user_id | UUID | Optional owner/tenant identifier |
671
- | organization_id | UUID | Optional owning organization; receives events for all its links |
672
- | name | VARCHAR(255) | Webhook name |
673
- | url | TEXT | Delivery URL |
674
- | secret | VARCHAR(255) | HMAC signing secret |
675
- | events | TEXT[] | Subscribed event types |
676
- | is_active | BOOLEAN | Active status |
677
- | retry_count| INTEGER | Max retries (default: 3) |
678
- | timeout_ms | INTEGER | Request timeout (default: 10000) |
679
- | headers | JSONB | Custom HTTP headers |
680
- | created_at | TIMESTAMP | Creation timestamp |
681
- | updated_at | TIMESTAMP | Last update timestamp |
682
-
683
- ## Utilities
684
-
685
- ### Generate Short Code
686
-
687
- ```typescript
688
- import { generateShortCode } from '@linkforty/core';
689
-
690
- const code = generateShortCode(8); // Returns 8-character nanoid
691
- ```
318
+ ## Reference
692
319
 
693
- ### Detect Device
694
-
695
- ```typescript
696
- import { detectDevice } from '@linkforty/core';
697
-
698
- const device = detectDevice(userAgent); // Returns 'ios' | 'android' | 'web'
699
- ```
700
-
701
- ### Get Location from IP
702
-
703
- ```typescript
704
- import { getLocationFromIP } from '@linkforty/core';
705
-
706
- const location = getLocationFromIP('8.8.8.8');
707
- // Returns: { countryCode, countryName, region, city, latitude, longitude, timezone }
708
- ```
709
-
710
- ### Build Redirect URL with UTM Parameters
711
-
712
- ```typescript
713
- import { buildRedirectUrl } from '@linkforty/core';
714
-
715
- const url = buildRedirectUrl('https://example.com', {
716
- source: 'twitter',
717
- medium: 'social',
718
- campaign: 'summer-sale'
719
- });
720
- // Returns: https://example.com?utm_source=twitter&utm_medium=social&utm_campaign=summer-sale
721
- ```
722
-
723
- ## Advanced Usage
724
-
725
- ### Custom Route Registration
726
-
727
- ```typescript
728
- import { createServer } from '@linkforty/core';
729
-
730
- const server = await createServer({
731
- database: { url: 'postgresql://localhost/linkforty' },
732
- });
733
-
734
- // Add custom routes
735
- server.get('/custom', async (request, reply) => {
736
- return { message: 'Hello World' };
737
- });
738
-
739
- await server.listen({ port: 3000 });
740
- ```
741
-
742
- ### Using Individual Route Handlers
743
-
744
- ```typescript
745
- import Fastify from 'fastify';
746
- import { initializeDatabase, redirectRoutes, linkRoutes } from '@linkforty/core';
747
-
748
- const fastify = Fastify();
749
-
750
- // Initialize database separately
751
- await initializeDatabase({ url: 'postgresql://localhost/linkforty' });
752
-
753
- // Register only specific routes
754
- await fastify.register(redirectRoutes);
755
- await fastify.register(linkRoutes);
756
-
757
- await fastify.listen({ port: 3000 });
758
- ```
759
-
760
- #### Starting while the database is busy
761
-
762
- `initializeDatabase()` creates or updates the schema on every start. Postgres takes a table's lock *before* it evaluates `IF NOT EXISTS`, so a statement such as `ALTER TABLE links ADD COLUMN IF NOT EXISTS …` for a column that already exists would still request an exclusive lock on `links` — and if a long statement (a retention job, a large export) holds that table, the request waits, and every later query on the table, redirects included, queues behind it until the long statement ends. To keep a routine start from ever doing that, statements whose effect already exists are answered from the catalog and never sent, and you can bound the wait for the rest:
763
-
764
- ```typescript
765
- await initializeDatabase({
766
- url: 'postgresql://localhost/linkforty',
767
- // A schema change that waits longer than this fails the start with
768
- // SQLSTATE 55P03 rather than queueing the table. Retry or restart.
769
- lockTimeoutMs: 5000,
770
- });
771
- ```
772
-
773
- The timeout applies only to the initialisation session and is reset before that connection returns to the pool. When adding schema statements of your own, use the idempotent shapes (`ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, `DROP CONSTRAINT IF EXISTS`) or a `DO $$ … IF NOT EXISTS (SELECT … FROM information_schema …)` block, never a bare `ALTER` that asks for a lock on every start.
320
+ - [Redirects](docs/redirects.md)
321
+ - [Configuration](docs/configuration.md): server options and hooks, `linkforty.config.json`, reverse proxies, environment variables
322
+ - [Database schema](docs/database-schema.md)
323
+ - [Using Core as a library](docs/library-usage.md)
324
+ - [Export and import](docs/export-import.md)
774
325
 
775
326
  ## Deployment
776
327
 
777
- LinkForty can be deployed in multiple ways depending on your needs:
778
-
779
- ### Production Deployment (Recommended)
780
-
781
- Deploy to managed platforms with minimal DevOps overhead:
782
-
783
- **Fly.io (Recommended)**
784
- - Global edge deployment
785
- - Managed PostgreSQL and Redis
786
- - Auto-scaling and SSL included
787
- - Starting at ~$10-15/month
788
-
789
- [View Fly.io deployment guide](infra/fly.io/DEPLOYMENT.md)
790
-
791
- See [`infra/`](infra/) directory for all deployment options and platform-specific guides.
792
-
793
- ### Docker Deployment (Recommended for Self-Hosting)
794
-
795
- **Production-ready Docker images available on Docker Hub:**
796
-
797
- ```bash
798
- # One-command deployment
799
- curl -O https://raw.githubusercontent.com/linkforty/core/main/docker-compose.yml
800
- docker compose up -d
801
- ```
802
-
803
- **Image Details:**
804
- - **Registry:** `linkforty/core`
805
- - **Tags:** `latest`, `v1.x.x`, `main`
806
- - **Architectures:** linux/amd64, linux/arm64
807
- - **Base:** Node.js 22 Alpine (minimal, secure)
808
- - **Security:** Non-root user, SBOM attestations
809
-
810
- **Version Pinning (Recommended):**
811
- ```yaml
812
- services:
813
- linkforty:
814
- image: linkforty/core:v1.5.0 # Pin to specific version
815
- ```
816
-
817
- See [DOCKER.md](DOCKER.md) for complete deployment guide including:
818
- - Environment configuration
819
- - Health checks
820
- - Backup strategies
821
- - Production best practices
822
-
823
- ### Manual Deployment
824
-
825
- For custom infrastructure needs:
826
-
827
- 1. Install dependencies: `npm install @linkforty/core`
828
- 2. Set up PostgreSQL database (13+)
829
- 3. Set up Redis (optional but recommended)
830
- 4. Run migrations: `npm run migrate`
831
- 5. Start server: `node server.js`
832
-
833
- ### Other Platforms
834
-
835
- Community-maintained templates available for:
836
- - AWS (ECS/Fargate)
837
- - Google Cloud Run
838
- - Railway, Render, and more
839
-
840
- See [`infra/CONTRIBUTING.md`](infra/CONTRIBUTING.md) to add support for additional platforms.
328
+ Core runs anywhere you can run a container with PostgreSQL 13+ (Redis is optional). See [DOCKER.md](DOCKER.md) for the image, Compose setup, health checks, backups and version pinning.
841
329
 
842
330
  ## Performance
843
331
 
@@ -949,6 +437,7 @@ LinkForty Core supports iOS Universal Links and Android App Links for seamless d
949
437
  |----------|---------|---------|
950
438
  | React Native | [`@linkforty/mobile-sdk-react-native`](https://github.com/LinkForty/mobile-sdk-react-native) | `npm install @linkforty/mobile-sdk-react-native` |
951
439
  | Expo | [`@linkforty/mobile-sdk-expo`](https://github.com/LinkForty/mobile-sdk-expo) | `npx expo install @linkforty/mobile-sdk-expo` |
440
+ | Flutter | [`linkforty_flutter`](https://github.com/LinkForty/mobile-sdk-flutter) | `flutter pub add linkforty_flutter` |
952
441
  | iOS (Swift) | [LinkFortySDK](https://github.com/LinkForty/mobile-sdk-ios) | Swift Package Manager |
953
442
  | Android (Kotlin) | [LinkFortySDK](https://github.com/LinkForty/mobile-sdk-android) | Gradle dependency |
954
443
 
@@ -968,49 +457,6 @@ adb shell am start -a android.intent.action.VIEW \
968
457
  ```
969
458
 
970
459
 
971
- ## Export and Import
972
-
973
- Move a server's links and analytics to another LinkForty server (self-hosted or LinkForty Cloud), or keep a backup, with a **bundle**: one gzipped file of newline-delimited JSON.
974
-
975
- ```bash
976
- # Everything on this server
977
- DATABASE_URL=postgres://... npx linkforty export backup.lfbundle
978
-
979
- # One organization's data
980
- DATABASE_URL=postgres://... npx linkforty export acme.lfbundle --organization <organization-id>
981
-
982
- # Check a bundle against a database without writing anything
983
- DATABASE_URL=postgres://... npx linkforty import acme.lfbundle --dry-run
984
-
985
- # Import it; add --organization <id> to import into an existing organization
986
- DATABASE_URL=postgres://... npx linkforty import acme.lfbundle
987
- ```
988
-
989
- **What a bundle carries:** organizations (name and the `appConfig` and `launchpad` settings), link templates, links, click events, device fingerprints, install events, in-app events and webhooks.
990
-
991
- **What it leaves out:**
992
- - **Webhook signing secrets.** Import issues new ones and prints them, so update your receivers.
993
- - **Link-safety state** (`warn_at`, `disabled_at`, `disabled_reason`, `suspended_at`), which belongs to the server that set it.
994
-
995
- **How import works:**
996
- - **Ids are kept**, so links, clicks and installs stay connected and apps keep their install ids.
997
- - **Checked first.** It reports conflicts before writing anything: ids already present, short codes already used in the target organization, template slugs already taken.
998
- - **All or nothing.** It writes everything in one transaction.
999
- - **Tamper check.** A bundle that was cut short or edited is refused, because its checksum no longer matches.
1000
-
1001
- **From code:** use `exportBundle`, `exportBundleToFile`, `importBundle` and `importBundleFile`, exported from the package root. `BUNDLE_TABLES` lists every column and whether a bundle carries it. A schema test fails when a column is added without a classification there.
1002
-
1003
- ## Migrate from Another Platform
1004
-
1005
- Switching from an existing deep linking provider? LinkForty supports zero-downtime migration via custom domain DNS cutover.
1006
-
1007
- - [Migrate from Branch.io](https://docs.linkforty.com/migrations/branch)
1008
- - [Migrate from AppsFlyer OneLink](https://docs.linkforty.com/migrations/appsflyer)
1009
- - [Migrate from Firebase Dynamic Links](https://docs.linkforty.com/comparisons/firebase-dynamic-links-migration) (shut down August 2025)
1010
- - [Migrate from Adjust](https://docs.linkforty.com/migrations/adjust)
1011
- - [Migrate from Kochava](https://docs.linkforty.com/migrations/kochava)
1012
- - [Migration overview and checklist](https://docs.linkforty.com/migrations/overview)
1013
-
1014
460
  ## For AI Tools (llms.txt)
1015
461
 
1016
462
  LinkForty provides machine-readable documentation for AI coding assistants (Claude, ChatGPT, Cursor, Copilot).
@@ -1034,26 +480,12 @@ Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for det
1034
480
 
1035
481
  AGPL-3.0 - see [LICENSE](LICENSE) file for details.
1036
482
 
1037
- ## Related Projects
483
+ ## Managed hosting
1038
484
 
1039
- - **[@linkforty/mobile-sdk-react-native](https://github.com/LinkForty/mobile-sdk-react-native)** - React Native SDK
1040
- - **[@linkforty/mobile-sdk-expo](https://github.com/LinkForty/mobile-sdk-expo)** - Expo SDK
1041
- - **[mobile-sdk-ios](https://github.com/LinkForty/mobile-sdk-ios)** - iOS SDK (Swift)
1042
- - **[mobile-sdk-android](https://github.com/LinkForty/mobile-sdk-android)** - Android SDK (Kotlin)
1043
- - **[LinkForty Cloud](https://go.linkforty.com/m0ycvnog/core-cloud)** - Hosted SaaS version with authentication, teams, billing, and dashboard
485
+ Prefer not to run it yourself? [LinkForty Cloud](https://go.linkforty.com/m0ycvnog/core-cloud) is the hosted version, with authentication, teams and a dashboard.
1044
486
 
1045
487
  ## Support
1046
488
 
1047
489
  - **Documentation**: [docs.linkforty.com](https://go.linkforty.com/m0ycvnog/core-docs)
1048
490
  - **Issues**: [GitHub Issues](https://github.com/linkforty/core/issues)
1049
491
  - **Discussions**: [GitHub Discussions](https://github.com/linkforty/core/discussions)
1050
-
1051
- ## Built with:
1052
- - [Fastify](https://www.fastify.io/) - Fast web framework
1053
- - [PostgreSQL](https://www.postgresql.org/) - Powerful database
1054
- - [Redis](https://redis.io/) - In-memory cache
1055
- - [Zod](https://zod.dev/) - TypeScript-first schema validation
1056
- - [nanoid](https://github.com/ai/nanoid) - Unique ID generation
1057
- - [geoip-lite](https://github.com/geoip-lite/node-geoip) - IP geolocation
1058
- - [ua-parser-js](https://github.com/faisalman/ua-parser-js) - User agent parsing
1059
- - [qrcode](https://github.com/soldair/node-qrcode) - QR code generation
package/llms.txt CHANGED
@@ -513,7 +513,7 @@ curl "http://localhost:3000/api/links/uuid/qr?format=svg" -o qr.svg
513
513
 
514
514
  ### Mobile SDK Endpoints (Public)
515
515
 
516
- These are called by the LinkForty mobile SDKs (`@linkforty/mobile-sdk-react-native`, `@linkforty/mobile-sdk-expo`, iOS SDK, Android SDK).
516
+ These are called by the LinkForty mobile SDKs (`@linkforty/mobile-sdk-react-native`, `@linkforty/mobile-sdk-expo`, `linkforty_flutter`, iOS SDK, Android SDK).
517
517
 
518
518
  #### POST /api/sdk/v1/install — Report App Install
519
519
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linkforty/core",
3
- "version": "1.44.2",
3
+ "version": "1.44.4",
4
4
  "type": "module",
5
5
  "description": "Open-source deeplink management engine with device detection and analytics",
6
6
  "main": "dist/index.js",