@linkforty/core 1.41.3 → 1.43.0

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 (45) hide show
  1. package/README.md +69 -2
  2. package/dist/index.config.test.d.ts +2 -0
  3. package/dist/index.config.test.d.ts.map +1 -0
  4. package/dist/index.config.test.js +87 -0
  5. package/dist/index.config.test.js.map +1 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +10 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/config.d.ts +40 -0
  11. package/dist/lib/config.d.ts.map +1 -0
  12. package/dist/lib/config.js +125 -0
  13. package/dist/lib/config.js.map +1 -0
  14. package/dist/lib/config.test.d.ts +2 -0
  15. package/dist/lib/config.test.d.ts.map +1 -0
  16. package/dist/lib/config.test.js +108 -0
  17. package/dist/lib/config.test.js.map +1 -0
  18. package/dist/lib/launchpad.d.ts +11 -0
  19. package/dist/lib/launchpad.d.ts.map +1 -1
  20. package/dist/lib/launchpad.js +13 -0
  21. package/dist/lib/launchpad.js.map +1 -1
  22. package/dist/lib/launchpad.test.js +23 -1
  23. package/dist/lib/launchpad.test.js.map +1 -1
  24. package/dist/lib/not-found-page.d.ts +106 -0
  25. package/dist/lib/not-found-page.d.ts.map +1 -0
  26. package/dist/lib/not-found-page.js +212 -0
  27. package/dist/lib/not-found-page.js.map +1 -0
  28. package/dist/lib/not-found-page.test.d.ts +2 -0
  29. package/dist/lib/not-found-page.test.d.ts.map +1 -0
  30. package/dist/lib/not-found-page.test.js +151 -0
  31. package/dist/lib/not-found-page.test.js.map +1 -0
  32. package/dist/routes/redirect.d.ts +31 -2
  33. package/dist/routes/redirect.d.ts.map +1 -1
  34. package/dist/routes/redirect.js +75 -10
  35. package/dist/routes/redirect.js.map +1 -1
  36. package/dist/routes/redirect.launchpad.test.js +75 -0
  37. package/dist/routes/redirect.launchpad.test.js.map +1 -1
  38. package/dist/routes/redirect.no-web-destination.test.js +2 -2
  39. package/dist/routes/redirect.no-web-destination.test.js.map +1 -1
  40. package/dist/routes/redirect.not-found.test.d.ts +2 -0
  41. package/dist/routes/redirect.not-found.test.d.ts.map +1 -0
  42. package/dist/routes/redirect.not-found.test.js +280 -0
  43. package/dist/routes/redirect.not-found.test.js.map +1 -0
  44. package/linkforty.config.example.json +20 -0
  45. package/package.json +3 -2
package/README.md CHANGED
@@ -245,6 +245,33 @@ GET /:templateSlug/:shortCode
245
245
 
246
246
  Automatically redirects users to the appropriate URL based on device type (iOS/Android/web), evaluates targeting rules, and tracks the click asynchronously.
247
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://ridealert.example", // send browsers here (302); the rest is ignored
258
+ "title": "This ride link has expired",
259
+ "message": "Open the app to see current rides.",
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://ridealert.example/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
+
248
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.
249
276
 
250
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.
@@ -271,7 +298,9 @@ When the link does have a web destination, the page offers it as a "Continue on
271
298
 
272
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.
273
300
 
274
- - `links.launchpad_mode` (per link, `launchpadMode` on the links API): `inherit` (default), `on` or `off`. The link's value wins over the workspace's.
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.
275
304
 
276
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.
277
306
 
@@ -380,6 +409,7 @@ interface ServerOptions {
380
409
  trustProxy?: boolean | number; // Trust X-Forwarded-For when behind a proxy (default: false)
381
410
  redirect?: RedirectRouteOptions; // See below
382
411
  sdk?: SdkRouteOptions; // See below
412
+ config?: LinkFortyConfig; // linkforty.config.json, from loadConfig()
383
413
  }
384
414
 
385
415
  interface RedirectRouteOptions {
@@ -406,6 +436,24 @@ interface RedirectRouteOptions {
406
436
  // association files on that host, so no installed app can claim its URLs.
407
437
  // A hook that throws is logged and treated as false.
408
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;
409
457
  }
410
458
 
411
459
  interface SdkRouteOptions {
@@ -422,7 +470,26 @@ interface SdkRouteOptions {
422
470
 
423
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.
424
472
 
425
- `renderLaunchpadPage()`, `defaultLaunchpadContent()` and `shouldServeLaunchpadOnDesktop()` are exported so a host can render the same page elsewhere (a settings preview, for instance).
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.
426
493
 
427
494
  ### Running behind a reverse proxy
428
495
 
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.config.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.config.test.d.ts","sourceRoot":"","sources":["../src/index.config.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * createServer() hands linkforty.config.json to the redirect routes.
3
+ *
4
+ * The real server with only the data layer mocked: what matters is that a
5
+ * config object from loadConfig() reaches the pages a visitor sees, and that
6
+ * options passed in code still take precedence over it.
7
+ */
8
+ import { describe, it, expect, vi, afterEach } from 'vitest';
9
+ // vi.mock is hoisted above these imports, so index.js still receives the mock.
10
+ import { createServer, parseConfig } from './index.js';
11
+ const query = vi.fn();
12
+ vi.mock('./lib/database.js', () => ({
13
+ SCHEMA_INIT_LOCK_KEY: 0,
14
+ db: { query: (...args) => query(...args) },
15
+ initializeDatabase: vi.fn(async () => undefined),
16
+ }));
17
+ const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
18
+ function mockLink(row) {
19
+ query.mockReset();
20
+ query.mockImplementation(async (sql) => {
21
+ if (/FROM links l/i.test(sql))
22
+ return { rows: row ? [row] : [], rowCount: row ? 1 : 0 };
23
+ return { rows: [], rowCount: 0 };
24
+ });
25
+ }
26
+ const link = {
27
+ id: '00000000-0000-0000-0000-0000000000aa',
28
+ short_code: 'abc123',
29
+ original_url: null,
30
+ web_fallback_url: 'https://example.com/page',
31
+ ios_app_store_url: 'https://apps.apple.com/app/id1',
32
+ android_app_store_url: null,
33
+ launchpad_mode: 'inherit',
34
+ is_active: true,
35
+ targeting_rules: null,
36
+ org_settings: null,
37
+ };
38
+ let server;
39
+ afterEach(async () => {
40
+ await new Promise((r) => setImmediate(r));
41
+ await server?.close();
42
+ server = undefined;
43
+ });
44
+ const visit = (url) => server.inject({ method: 'GET', url, headers: { 'user-agent': DESKTOP_UA, accept: 'text/html' } });
45
+ describe('createServer config', () => {
46
+ it('serves the not-found page and Launchpad from the config file', async () => {
47
+ const config = parseConfig({
48
+ notFound: { title: 'Nothing here' },
49
+ launchpad: { desktop: 'always', appName: 'Instance App' },
50
+ });
51
+ server = await createServer({ logger: false, config });
52
+ mockLink(null);
53
+ expect((await visit('/nosuchcode')).body).toContain('<h1>Nothing here</h1>');
54
+ mockLink(link);
55
+ const page = await visit('/abc123');
56
+ expect(page.statusCode).toBe(200);
57
+ expect(page.body).toContain('<span>Instance App</span>');
58
+ });
59
+ it('keeps hooks passed in code alongside the config defaults', async () => {
60
+ const resolveContent = vi.fn().mockReturnValue(null);
61
+ server = await createServer({
62
+ logger: false,
63
+ config: parseConfig({ launchpad: { desktop: 'always' } }),
64
+ redirect: { launchpad: { resolveContent } },
65
+ });
66
+ mockLink(link);
67
+ expect((await visit('/abc123')).statusCode).toBe(200);
68
+ expect(resolveContent).toHaveBeenCalledOnce();
69
+ });
70
+ it('lets options in code take precedence over the file', async () => {
71
+ server = await createServer({
72
+ logger: false,
73
+ config: parseConfig({ notFound: { title: 'From file' }, launchpad: { desktop: 'always' } }),
74
+ redirect: { notFoundDefaults: { title: 'From code' }, launchpad: { defaults: { desktop: 'off' } } },
75
+ });
76
+ mockLink(null);
77
+ expect((await visit('/nosuchcode')).body).toContain('<h1>From code</h1>');
78
+ mockLink(link);
79
+ expect((await visit('/abc123')).statusCode).toBe(302);
80
+ });
81
+ it('behaves as before without a config', async () => {
82
+ server = await createServer({ logger: false });
83
+ mockLink(link);
84
+ expect((await visit('/abc123')).statusCode).toBe(302);
85
+ });
86
+ });
87
+ //# sourceMappingURL=index.config.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.config.test.js","sourceRoot":"","sources":["../src/index.config.test.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify';
2
2
  import { DatabaseOptions } from './lib/database.js';
3
3
  import { type RedirectRouteOptions } from './routes/redirect.js';
4
4
  import { type SdkRouteOptions } from './routes/sdk.js';
5
+ import type { LinkFortyConfig } from './lib/config.js';
5
6
  /**
6
7
  * Configuration options for creating a LinkForty server instance.
7
8
  */
@@ -20,6 +21,11 @@ export interface ServerOptions {
20
21
  redirect?: RedirectRouteOptions;
21
22
  /** Options for the SDK routes: install match scoping. */
22
23
  sdk?: SdkRouteOptions;
24
+ /**
25
+ * The instance's settings from linkforty.config.json, as loadConfig() returns
26
+ * them. Options passed in code take precedence over the file.
27
+ */
28
+ config?: LinkFortyConfig;
23
29
  }
24
30
  /**
25
31
  * Create and configure a LinkForty Fastify server instance.
@@ -40,6 +46,8 @@ export * from './lib/link-lookup.js';
40
46
  export * from './lib/portable/index.js';
41
47
  export * from './lib/link-safety.js';
42
48
  export * from './lib/launchpad.js';
49
+ export * from './lib/not-found-page.js';
50
+ export * from './lib/config.js';
43
51
  export * from './lib/event-emitter.js';
44
52
  export * from './types/index.js';
45
53
  export type { RedirectRouteOptions } from './routes/redirect.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAGnD,OAAO,EAAsB,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAkB,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAGjF,OAAO,EAAa,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAOlE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,KAAK,CAAC,EAAE;QACN,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;IACF,IAAI,CAAC,EAAE;QACL,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;KAC3B,CAAC;IACF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oJAAoJ;IACpJ,UAAU,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC9B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IAChC,yDAAyD;IACzD,GAAG,CAAC,EAAE,eAAe,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,OAAO,GAAE,aAAkB,kTAiC7D;AAGD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,YAAY,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACjE,YAAY,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,cAAc,EAAE,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAGnD,OAAO,EAAsB,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAkB,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAGjF,OAAO,EAAa,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAMlE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,KAAK,CAAC,EAAE;QACN,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;IACF,IAAI,CAAC,EAAE;QACL,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;KAC3B,CAAC;IACF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oJAAoJ;IACpJ,UAAU,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC9B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IAChC,yDAAyD;IACzD,GAAG,CAAC,EAAE,eAAe,CAAC;IACtB;;;OAGG;IACH,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,OAAO,GAAE,aAAkB,kTAwC7D;AAGD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,yBAAyB,CAAC;AACxC,cAAc,iBAAiB,CAAC;AAChC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,YAAY,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACjE,YAAY,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,cAAc,EAAE,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -40,7 +40,14 @@ export async function createServer(options = {}) {
40
40
  // Routes
41
41
  await fastify.register(healthRoutes);
42
42
  await fastify.register(wellKnownRoutes);
43
- await fastify.register(redirectRoutes, options.redirect ?? {});
43
+ await fastify.register(redirectRoutes, {
44
+ ...options.redirect,
45
+ notFoundDefaults: options.redirect?.notFoundDefaults ?? options.config?.notFound,
46
+ launchpad: {
47
+ ...options.redirect?.launchpad,
48
+ defaults: options.redirect?.launchpad?.defaults ?? options.config?.launchpad,
49
+ },
50
+ });
44
51
  await fastify.register(linkRoutes);
45
52
  await fastify.register(analyticsRoutes);
46
53
  await fastify.register(sdkRoutes, options.sdk ?? {});
@@ -59,6 +66,8 @@ export * from './lib/link-lookup.js';
59
66
  export * from './lib/portable/index.js';
60
67
  export * from './lib/link-safety.js';
61
68
  export * from './lib/launchpad.js';
69
+ export * from './lib/not-found-page.js';
70
+ export * from './lib/config.js';
62
71
  export * from './lib/event-emitter.js';
63
72
  export * from './types/index.js';
64
73
  export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes, healthRoutes } from './routes/index.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ import type { NotFoundSettings } from './not-found-page.js';
2
+ import type { LaunchpadSettings } from './launchpad.js';
3
+ /** File looked for in the working directory when LINKFORTY_CONFIG is unset. */
4
+ export declare const DEFAULT_CONFIG_FILE = "linkforty.config.json";
5
+ export interface LinkFortyConfig {
6
+ /** What a browser sees when a link can't be served (lib/not-found-page.ts). */
7
+ notFound?: NotFoundSettings;
8
+ /**
9
+ * The instance's Launchpad settings (lib/launchpad.ts). An organization's
10
+ * `settings.launchpad` overrides them field by field. `templateId` is
11
+ * organization-only: it names a host-rendered template.
12
+ */
13
+ launchpad?: Omit<LaunchpadSettings, 'templateId'>;
14
+ }
15
+ export declare class ConfigError extends Error {
16
+ readonly path: string;
17
+ constructor(message: string, path: string);
18
+ }
19
+ /** Validate an already-parsed configuration object. `path` names it in errors. */
20
+ export declare function parseConfig(raw: unknown, path?: string): LinkFortyConfig;
21
+ /**
22
+ * Load the configuration file.
23
+ *
24
+ * Reads the file named by `options.path`, else by LINKFORTY_CONFIG, else
25
+ * `linkforty.config.json` in the working directory. A file named explicitly
26
+ * must exist; the default one is optional. Returns the file's path, or null
27
+ * when none was read.
28
+ *
29
+ * @throws ConfigError when the file can't be read, isn't JSON, or has an
30
+ * invalid or unknown setting.
31
+ */
32
+ export declare function loadConfig(options?: {
33
+ path?: string;
34
+ env?: Record<string, string | undefined>;
35
+ cwd?: string;
36
+ }): {
37
+ config: LinkFortyConfig;
38
+ path: string | null;
39
+ };
40
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAExD,+EAA+E;AAC/E,eAAO,MAAM,mBAAmB,0BAA0B,CAAC;AAE3D,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B;;;;OAIG;IACH,SAAS,CAAC,EAAE,IAAI,CAAC,iBAAiB,EAAE,YAAY,CAAC,CAAC;CACnD;AAED,qBAAa,WAAY,SAAQ,KAAK;IAGlC,QAAQ,CAAC,IAAI,EAAE,MAAM;gBADrB,OAAO,EAAE,MAAM,EACN,IAAI,EAAE,MAAM;CAKxB;AAgED,kFAAkF;AAClF,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,SAAsB,GAAG,eAAe,CAMrF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CACxB,OAAO,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAO,GACtF;IAAE,MAAM,EAAE,eAAe,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAkBlD"}
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The instance's configuration file, `linkforty.config.json`.
3
+ *
4
+ * Configuration is split the way most self-hosted platforms split it:
5
+ *
6
+ * - environment variables hold infrastructure and secrets: where the
7
+ * database is, which port to listen on, and the path to this file;
8
+ * - this file holds the instance's defaults and behavior, including text a
9
+ * visitor reads;
10
+ * - `organizations.settings` holds per-organization values, which take
11
+ * precedence over this file where both apply.
12
+ *
13
+ * The file is optional: without one every built-in default applies. A file that
14
+ * exists but is wrong stops the server at startup with a message naming the bad
15
+ * key. The operator wrote it on purpose, so a mistake should show immediately
16
+ * rather than as a page that quietly ignores them.
17
+ */
18
+ import { existsSync, readFileSync } from 'fs';
19
+ import { resolve } from 'path';
20
+ import { z } from 'zod';
21
+ /** File looked for in the working directory when LINKFORTY_CONFIG is unset. */
22
+ export const DEFAULT_CONFIG_FILE = 'linkforty.config.json';
23
+ export class ConfigError extends Error {
24
+ constructor(message, path) {
25
+ super(`${path}: ${message}`);
26
+ this.path = path;
27
+ this.name = 'ConfigError';
28
+ }
29
+ }
30
+ const httpUrl = z
31
+ .string()
32
+ .trim()
33
+ .refine((value) => {
34
+ try {
35
+ const { protocol } = new URL(value);
36
+ return protocol === 'http:' || protocol === 'https:';
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }, 'must be an http(s) URL');
42
+ const hexColor = z
43
+ .string()
44
+ .trim()
45
+ .regex(/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i, 'must be a hex color such as #0f766e');
46
+ const notFoundSchema = z
47
+ .object({
48
+ redirectUrl: httpUrl.optional(),
49
+ title: z.string().trim().min(1).max(120).optional(),
50
+ message: z.string().trim().min(1).max(500).optional(),
51
+ logoUrl: httpUrl.optional(),
52
+ accentColor: hexColor.optional(),
53
+ backgroundColor: hexColor.optional(),
54
+ buttonLabel: z.string().trim().min(1).max(60).optional(),
55
+ buttonUrl: httpUrl.optional(),
56
+ })
57
+ .strict()
58
+ .refine((value) => !value.buttonLabel === !value.buttonUrl, {
59
+ message: 'buttonLabel and buttonUrl must be set together',
60
+ });
61
+ /** The same limits readLaunchpadSettings applies to an organization's settings. */
62
+ const launchpadSchema = z
63
+ .object({
64
+ desktop: z.enum(['no-destination', 'always', 'off']).optional(),
65
+ mobile: z.enum(['store', 'page']).optional(),
66
+ appName: z.string().trim().min(1).max(60).optional(),
67
+ appIconUrl: httpUrl.optional(),
68
+ appWebsiteUrl: httpUrl.optional(),
69
+ // Six digits only: the page mixes these colors to derive its palette.
70
+ accentColor: z.string().trim().regex(/^#[0-9a-f]{6}$/i, 'must be a six-digit hex color such as #0f766e').optional(),
71
+ backgroundColor: z.string().trim().regex(/^#[0-9a-f]{6}$/i, 'must be a six-digit hex color such as #101418').optional(),
72
+ })
73
+ .strict();
74
+ const configSchema = z
75
+ .object({
76
+ notFound: notFoundSchema.optional(),
77
+ launchpad: launchpadSchema.optional(),
78
+ })
79
+ .strict();
80
+ function describeIssue(issue) {
81
+ if (issue.code === 'unrecognized_keys') {
82
+ const where = issue.path.length ? `${issue.path.join('.')}: ` : '';
83
+ return `${where}unknown setting ${issue.keys.map((k) => `"${k}"`).join(', ')}`;
84
+ }
85
+ return issue.path.length ? `${issue.path.join('.')}: ${issue.message}` : issue.message;
86
+ }
87
+ /** Validate an already-parsed configuration object. `path` names it in errors. */
88
+ export function parseConfig(raw, path = DEFAULT_CONFIG_FILE) {
89
+ const result = configSchema.safeParse(raw);
90
+ if (!result.success) {
91
+ throw new ConfigError(result.error.issues.map(describeIssue).join('; '), path);
92
+ }
93
+ return result.data;
94
+ }
95
+ /**
96
+ * Load the configuration file.
97
+ *
98
+ * Reads the file named by `options.path`, else by LINKFORTY_CONFIG, else
99
+ * `linkforty.config.json` in the working directory. A file named explicitly
100
+ * must exist; the default one is optional. Returns the file's path, or null
101
+ * when none was read.
102
+ *
103
+ * @throws ConfigError when the file can't be read, isn't JSON, or has an
104
+ * invalid or unknown setting.
105
+ */
106
+ export function loadConfig(options = {}) {
107
+ const env = options.env ?? process.env;
108
+ const named = options.path || env.LINKFORTY_CONFIG || '';
109
+ const path = resolve(options.cwd ?? process.cwd(), named || DEFAULT_CONFIG_FILE);
110
+ if (!existsSync(path)) {
111
+ if (named)
112
+ throw new ConfigError('file not found', path);
113
+ return { config: {}, path: null };
114
+ }
115
+ let raw;
116
+ try {
117
+ raw = JSON.parse(readFileSync(path, 'utf8'));
118
+ }
119
+ catch (error) {
120
+ const reason = error instanceof SyntaxError ? `not valid JSON (${error.message})` : 'could not be read';
121
+ throw new ConfigError(reason, path);
122
+ }
123
+ return { config: parseConfig(raw, path), path };
124
+ }
125
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=config.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.test.d.ts","sourceRoot":"","sources":["../../src/lib/config.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ import { ConfigError, loadConfig, parseConfig } from './config.js';
6
+ let dir;
7
+ beforeEach(() => {
8
+ dir = mkdtempSync(join(tmpdir(), 'linkforty-config-'));
9
+ });
10
+ afterEach(() => {
11
+ rmSync(dir, { recursive: true, force: true });
12
+ });
13
+ function write(name, contents) {
14
+ const path = join(dir, name);
15
+ writeFileSync(path, typeof contents === 'string' ? contents : JSON.stringify(contents));
16
+ return path;
17
+ }
18
+ function errorFrom(fn) {
19
+ try {
20
+ fn();
21
+ }
22
+ catch (error) {
23
+ expect(error).toBeInstanceOf(ConfigError);
24
+ return error;
25
+ }
26
+ throw new Error('expected a ConfigError');
27
+ }
28
+ describe('loadConfig', () => {
29
+ it('uses the defaults when there is no file', () => {
30
+ expect(loadConfig({ cwd: dir, env: {} })).toEqual({ config: {}, path: null });
31
+ });
32
+ it('reads linkforty.config.json from the working directory', () => {
33
+ const path = write('linkforty.config.json', { notFound: { title: 'Gone' } });
34
+ expect(loadConfig({ cwd: dir, env: {} })).toEqual({ config: { notFound: { title: 'Gone' } }, path });
35
+ });
36
+ it('reads the file named by LINKFORTY_CONFIG', () => {
37
+ const path = write('custom.json', { notFound: { redirectUrl: 'https://example.com/' } });
38
+ expect(loadConfig({ cwd: '/', env: { LINKFORTY_CONFIG: path } }).config).toEqual({
39
+ notFound: { redirectUrl: 'https://example.com/' },
40
+ });
41
+ });
42
+ it('fails when a named file is missing', () => {
43
+ const error = errorFrom(() => loadConfig({ cwd: dir, env: { LINKFORTY_CONFIG: 'missing.json' } }));
44
+ expect(error.message).toContain('missing.json: file not found');
45
+ });
46
+ it('fails on invalid JSON', () => {
47
+ write('linkforty.config.json', '{ "notFound": { title: "x" } }');
48
+ expect(errorFrom(() => loadConfig({ cwd: dir, env: {} })).message).toContain('not valid JSON');
49
+ });
50
+ it('trims values', () => {
51
+ write('linkforty.config.json', { notFound: { title: ' Gone ' } });
52
+ expect(loadConfig({ cwd: dir, env: {} }).config.notFound?.title).toBe('Gone');
53
+ });
54
+ });
55
+ describe('parseConfig', () => {
56
+ it('accepts every notFound setting', () => {
57
+ const notFound = {
58
+ redirectUrl: 'https://example.com/',
59
+ title: 'Gone',
60
+ message: 'Try the homepage.',
61
+ logoUrl: 'https://cdn.example.com/logo.png',
62
+ accentColor: '#0f766e',
63
+ backgroundColor: '#f6f7f8',
64
+ buttonLabel: 'Home',
65
+ buttonUrl: 'https://example.com/',
66
+ };
67
+ expect(parseConfig({ notFound })).toEqual({ notFound });
68
+ });
69
+ it.each([
70
+ [{ notFound: { redirectUrl: 'javascript:alert(1)' } }, 'notFound.redirectUrl: must be an http(s) URL'],
71
+ [{ notFound: { accentColor: 'teal' } }, 'notFound.accentColor: must be a hex color'],
72
+ [{ notFound: { buttonLabel: 'Home' } }, 'buttonLabel and buttonUrl must be set together'],
73
+ [{ notFound: { titel: 'Gone' } }, 'notFound: unknown setting "titel"'],
74
+ [{ notfound: {} }, 'unknown setting "notfound"'],
75
+ [{ notFound: { title: 42 } }, 'notFound.title'],
76
+ [{ notFound: { title: 'x'.repeat(121) } }, 'notFound.title'],
77
+ ])('names the bad setting: %j', (raw, expected) => {
78
+ const error = errorFrom(() => parseConfig(raw, 'linkforty.config.json'));
79
+ expect(error.message).toContain('linkforty.config.json: ');
80
+ expect(error.message).toContain(expected);
81
+ });
82
+ it('accepts every launchpad setting', () => {
83
+ const launchpad = {
84
+ desktop: 'always',
85
+ mobile: 'page',
86
+ appName: 'Ride Alert',
87
+ appIconUrl: 'https://cdn.example.com/icon.png',
88
+ appWebsiteUrl: 'https://ridealert.example',
89
+ accentColor: '#0f766e',
90
+ backgroundColor: '#101418',
91
+ };
92
+ expect(parseConfig({ launchpad })).toEqual({ launchpad });
93
+ });
94
+ it.each([
95
+ [{ launchpad: { desktop: 'sometimes' } }, 'launchpad.desktop'],
96
+ [{ launchpad: { accentColor: '#abc' } }, 'launchpad.accentColor: must be a six-digit hex color'],
97
+ [{ launchpad: { appIconUrl: 'ftp://cdn.example/icon.png' } }, 'launchpad.appIconUrl: must be an http(s) URL'],
98
+ [{ launchpad: { templateId: 'hero-1' } }, 'launchpad: unknown setting "templateId"'],
99
+ [{ launchpad: { appname: 'x' } }, 'launchpad: unknown setting "appname"'],
100
+ [{ launchpad: { appName: 'x'.repeat(61) } }, 'launchpad.appName'],
101
+ ])('names the bad launchpad setting: %j', (raw, expected) => {
102
+ expect(errorFrom(() => parseConfig(raw)).message).toContain(expected);
103
+ });
104
+ it('rejects a file that is not an object', () => {
105
+ expect(() => parseConfig([])).toThrow(ConfigError);
106
+ });
107
+ });
108
+ //# sourceMappingURL=config.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.test.js","sourceRoot":"","sources":["../../src/lib/config.test.ts"],"names":[],"mappings":"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"}
@@ -119,6 +119,17 @@ export interface LaunchpadLinkFields {
119
119
  * default, a bad colour is dropped, and image URLs must be http(s).
120
120
  */
121
121
  export declare function readLaunchpadSettings(orgSettings: unknown): LaunchpadSettings;
122
+ /**
123
+ * The settings a request uses: the instance's defaults (linkforty.config.json),
124
+ * overridden field by field by the organization's `settings.launchpad`.
125
+ *
126
+ * Per field rather than as a whole, unlike the not-found page's settings:
127
+ * organizations routinely set one or two fields (a color, a mode), and replacing
128
+ * the instance's settings wholesale would drop its app name and icon for them.
129
+ * An organization value that `readLaunchpadSettings` rejects is simply absent,
130
+ * so it never erases a valid instance value.
131
+ */
132
+ export declare function mergeLaunchpadSettings(defaults: LaunchpadSettings | undefined, orgSettings: unknown): LaunchpadSettings;
122
133
  /** Normalise `links.launchpad_mode`, which a cached row from before the column existed may lack. */
123
134
  export declare function readLaunchpadLinkMode(value: unknown): LaunchpadLinkMode;
124
135
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"launchpad.d.ts","sourceRoot":"","sources":["../../src/lib/launchpad.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"launchpad.d.ts","sourceRoot":"","sources":["../../src/lib/launchpad.ts"],"names":[],"mappings":"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"}