@linkforty/core 1.25.4 → 1.27.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.
package/README.md CHANGED
@@ -241,6 +241,32 @@ Automatically redirects users to the appropriate URL based on device type (iOS/A
241
241
 
242
242
  **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.
243
243
 
244
+ **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, App Store / Google Play buttons, 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.
245
+
246
+ When the link does have a web destination, the page offers it as a "Continue on the web" link, 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:
247
+
248
+ - `organizations.settings.launchpad` (per workspace):
249
+
250
+ ```json
251
+ {
252
+ "launchpad": {
253
+ "desktop": "no-destination", // "no-destination" (default) | "always" | "off"
254
+ "mobile": "store", // "store" (default) | "page"
255
+ "appName": "Ride Alert",
256
+ "appIconUrl": "https://cdn.example/icon.png",
257
+ "accentColor": "#0f766e"
258
+ }
259
+ }
260
+ ```
261
+
262
+ - `links.launchpad_mode` (per link, `launchpadMode` on the links API): `inherit` (default), `on` or `off`. The link's value wins over the workspace's.
263
+
264
+ With `desktop: "off"` (or `launchpad_mode: "off"`), a link with no web destination gets the plain "This link opens in an app" page instead.
265
+
266
+ **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.
267
+
268
+ Two hooks on `RedirectRouteOptions` let a host application extend the page without forking it — see [Server Options](#server-options).
269
+
244
270
  ### QR Codes
245
271
 
246
272
  ```bash
@@ -322,9 +348,28 @@ interface ServerOptions {
322
348
  };
323
349
  logger?: boolean; // Enable Fastify logger (default: true)
324
350
  trustProxy?: boolean | number; // Trust X-Forwarded-For when behind a proxy (default: false)
351
+ redirect?: RedirectRouteOptions; // See below
352
+ }
353
+
354
+ interface RedirectRouteOptions {
355
+ abuseReportUrl?: string; // Linked from the link-safety warning page
356
+ launchpad?: {
357
+ // Supply richer content for the launchpad page than the link row carries:
358
+ // a rendered share image, a templated hero. Return null for the default.
359
+ // A hook that throws is logged and treated as null — the page never fails
360
+ // because of it. `heroHtml` is inserted unescaped: render it yourself,
361
+ // never from end-user input.
362
+ resolveContent?: (link, settings) => Promise<LaunchpadContent | null>;
363
+ // Endpoint that receives `{ linkId, event }` beacons from the page —
364
+ // "view" on load, "cta_ios" / "cta_android" / "cta_open" on tap. No
365
+ // cookies or identifiers. Unset means the page sends nothing.
366
+ beaconUrl?: string;
367
+ };
325
368
  }
326
369
  ```
327
370
 
371
+ `renderLaunchpadPage()`, `defaultLaunchpadContent()` and `shouldServeLaunchpadOnDesktop()` are exported so a host can render the same page elsewhere (a settings preview, for instance).
372
+
328
373
  ### Running behind a reverse proxy
329
374
 
330
375
  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.
@@ -378,6 +423,7 @@ Core does not create a `users` table. Authentication and user management are the
378
423
  | og_description | TEXT | Open Graph description |
379
424
  | og_image_url | TEXT | Open Graph image URL |
380
425
  | og_type | VARCHAR(50) | Open Graph type (default: "website") |
426
+ | launchpad_mode | VARCHAR(10) | Launchpad page: inherit / on / off |
381
427
  | attribution_window_hours| INTEGER | Install attribution window (default: 168)|
382
428
  | is_active | BOOLEAN | Active status |
383
429
  | expires_at | TIMESTAMP | Expiration date |
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { FastifyInstance } from 'fastify';
2
2
  import { DatabaseOptions } from './lib/database.js';
3
+ import { type RedirectRouteOptions } from './routes/redirect.js';
3
4
  /**
4
5
  * Configuration options for creating a LinkForty server instance.
5
6
  */
@@ -14,6 +15,8 @@ export interface ServerOptions {
14
15
  logger?: boolean;
15
16
  /** When true or a number (proxy hop count), Fastify trusts X-Forwarded-For so request.ip is the real client IP. Set when behind a reverse proxy. */
16
17
  trustProxy?: boolean | number;
18
+ /** Options for the redirect routes: abuse-report link, launchpad page hooks. */
19
+ redirect?: RedirectRouteOptions;
17
20
  }
18
21
  /**
19
22
  * Create and configure a LinkForty Fastify server instance.
@@ -31,7 +34,9 @@ export * from './lib/database.js';
31
34
  export * from './lib/fingerprint.js';
32
35
  export * from './lib/webhook.js';
33
36
  export * from './lib/link-safety.js';
37
+ export * from './lib/launchpad.js';
34
38
  export * from './lib/event-emitter.js';
35
39
  export * from './types/index.js';
40
+ export type { RedirectRouteOptions } from './routes/redirect.js';
36
41
  export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes, healthRoutes } from './routes/index.js';
37
42
  //# sourceMappingURL=index.d.ts.map
@@ -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;AAWxE;;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;CAC/B;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,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,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;AAUjF;;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;CACjC;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,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,YAAY,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACjE,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,7 @@ 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);
43
+ await fastify.register(redirectRoutes, options.redirect ?? {});
44
44
  await fastify.register(linkRoutes);
45
45
  await fastify.register(analyticsRoutes);
46
46
  await fastify.register(sdkRoutes);
@@ -56,6 +56,7 @@ export * from './lib/database.js';
56
56
  export * from './lib/fingerprint.js';
57
57
  export * from './lib/webhook.js';
58
58
  export * from './lib/link-safety.js';
59
+ export * from './lib/launchpad.js';
59
60
  export * from './lib/event-emitter.js';
60
61
  export * from './types/index.js';
61
62
  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":"AAAA,OAAO,OAA4B,MAAM,SAAS,CAAC;AACnD,OAAO,IAAI,MAAM,eAAe,CAAC;AACjC,OAAO,KAAK,MAAM,gBAAgB,CAAC;AACnC,OAAO,EAAE,kBAAkB,EAAmB,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAkBlD;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,UAAyB,EAAE;IAC5D,MAAM,OAAO,GAAG,OAAO,CAAC;QACtB,MAAM,EAAE,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI;QAC5D,UAAU,EAAE,OAAO,CAAC,UAAU;KAC/B,CAAC,CAAC;IAEH,OAAO;IACP,MAAM,OAAO,CAAC,QAAQ,CAAC,IAAI,EAAE;QAC3B,MAAM,EAAE,OAAO,CAAC,IAAI,EAAE,MAAM,IAAI,GAAG;KACpC,CAAC,CAAC;IAEH,mBAAmB;IACnB,IAAI,OAAO,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC;QACvB,MAAM,OAAO,CAAC,QAAQ,CAAC,KAAK,EAAE;YAC5B,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG;SACvB,CAAC,CAAC;IACL,CAAC;IAED,WAAW;IACX,MAAM,kBAAkB,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAE3C,SAAS;IACT,MAAM,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;IACrC,MAAM,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IACvC,MAAM,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;IACnC,MAAM,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC;IACtC,MAAM,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IACvC,MAAM,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAEjC,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,gCAAgC;AAChC,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,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,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.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,OAA4B,MAAM,SAAS,CAAC;AACnD,OAAO,IAAI,MAAM,eAAe,CAAC;AACjC,OAAO,KAAK,MAAM,gBAAgB,CAAC;AACnC,OAAO,EAAE,kBAAkB,EAAmB,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAE,cAAc,EAA6B,MAAM,sBAAsB,CAAC;AACjF,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAoBlD;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,UAAyB,EAAE;IAC5D,MAAM,OAAO,GAAG,OAAO,CAAC;QACtB,MAAM,EAAE,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI;QAC5D,UAAU,EAAE,OAAO,CAAC,UAAU;KAC/B,CAAC,CAAC;IAEH,OAAO;IACP,MAAM,OAAO,CAAC,QAAQ,CAAC,IAAI,EAAE;QAC3B,MAAM,EAAE,OAAO,CAAC,IAAI,EAAE,MAAM,IAAI,GAAG;KACpC,CAAC,CAAC;IAEH,mBAAmB;IACnB,IAAI,OAAO,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC;QACvB,MAAM,OAAO,CAAC,QAAQ,CAAC,KAAK,EAAE;YAC5B,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG;SACvB,CAAC,CAAC;IACL,CAAC;IAED,WAAW;IACX,MAAM,kBAAkB,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAE3C,SAAS;IACT,MAAM,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;IACrC,MAAM,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,OAAO,CAAC,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC;IAC/D,MAAM,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;IACnC,MAAM,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC;IACtC,MAAM,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IACvC,MAAM,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAEjC,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,gCAAgC;AAChC,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,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AAEjC,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 +1 @@
1
- {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/lib/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAIpB,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE;QACL,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,GAAG,CAAC,EAAE,MAAM,CAAC;KACd,CAAC;CACH;AAED,eAAO,IAAI,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC;AA4BvB;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,UAAU,CAAC;AAG5C,wBAAsB,kBAAkB,CAAC,OAAO,GAAE,eAAoB,iBAmnBrE"}
1
+ {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/lib/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAIpB,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE;QACL,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,GAAG,CAAC,EAAE,MAAM,CAAC;KACd,CAAC;CACH;AAED,eAAO,IAAI,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC;AA4BvB;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,UAAU,CAAC;AAG5C,wBAAsB,kBAAkB,CAAC,OAAO,GAAE,eAAoB,iBAuoBrE"}
@@ -335,6 +335,25 @@ export async function initializeDatabase(options = {}) {
335
335
  ALTER TABLE links ADD COLUMN og_image_url TEXT;
336
336
  END IF;
337
337
  END $$;
338
+ `);
339
+ // Per-link override for the launchpad page (see lib/launchpad.ts):
340
+ // inherit — follow the workspace setting (the default)
341
+ // on — serve the page to desktop visitors even when the link has a
342
+ // web destination
343
+ // off — never serve it; a link with no web destination gets the plain
344
+ // "opens in an app" page instead
345
+ // NOT NULL with a default, so a cached row from before the column existed
346
+ // and a freshly inserted row read the same way.
347
+ await client.query(`
348
+ DO $$
349
+ BEGIN
350
+ IF NOT EXISTS (
351
+ SELECT 1 FROM information_schema.columns
352
+ WHERE table_name='links' AND column_name='launchpad_mode'
353
+ ) THEN
354
+ ALTER TABLE links ADD COLUMN launchpad_mode VARCHAR(10) NOT NULL DEFAULT 'inherit';
355
+ END IF;
356
+ END $$;
338
357
  `);
339
358
  await client.query(`
340
359
  DO $$
@@ -1 +1 @@
1
- {"version":3,"file":"database.js","sourceRoot":"","sources":["../../src/lib/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAEpB,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;AAUpB,MAAM,CAAC,IAAI,EAAW,CAAC;AAEvB,+CAA+C;AAC/C,SAAS,KAAK,CAAC,EAAU;IACvB,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;AACzD,CAAC;AAED,qDAAqD;AACrD,KAAK,UAAU,gBAAgB,CAAC,aAAqB,EAAE,EAAE,YAAoB,IAAI;IAC/E,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,UAAU,EAAE,OAAO,EAAE,EAAE,CAAC;QACvD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,OAAO,EAAE,CAAC;YAClC,OAAO,CAAC,GAAG,CAAC,8CAA8C,CAAC,CAAC;YAC5D,OAAO,MAAM,CAAC;QAChB,CAAC;QAAC,OAAO,KAAU,EAAE,CAAC;YACpB,IAAI,KAAK,CAAC,IAAI,KAAK,cAAc,IAAI,OAAO,GAAG,UAAU,EAAE,CAAC;gBAC1D,MAAM,KAAK,GAAG,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,sBAAsB;gBAC1E,OAAO,CAAC,GAAG,CAAC,+BAA+B,OAAO,wBAAwB,KAAK,OAAO,CAAC,CAAC;gBACxF,MAAM,KAAK,CAAC,KAAK,CAAC,CAAC;YACrB,CAAC;iBAAM,CAAC;gBACN,OAAO,CAAC,KAAK,CAAC,kDAAkD,EAAE,KAAK,CAAC,CAAC;gBACzE,MAAM,KAAK,CAAC;YACd,CAAC;QACH,CAAC;IACH,CAAC;IACD,MAAM,IAAI,KAAK,CAAC,sBAAsB,CAAC,CAAC;AAC1C,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,OAAO,CAAC;AAE5C,6BAA6B;AAC7B,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,UAA2B,EAAE;IACpE,kBAAkB;IAClB,EAAE,GAAG,IAAI,IAAI,CAAC;QACZ,gBAAgB,EAAE,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,yDAAyD;QACtH,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,CAAC,CAAC,CAAC,EAAE,kBAAkB,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK;QAClF,GAAG,EAAE,OAAO,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC;QAC3B,GAAG,EAAE,OAAO,CAAC,IAAI,EAAE,GAAG,IAAI,EAAE;KAC7B,CAAC,CAAC;IAEH,MAAM,MAAM,GAAG,MAAM,gBAAgB,EAAE,CAAC;IAExC,oDAAoD;IACpD,EAAE;IACF,0EAA0E;IAC1E,0EAA0E;IAC1E,yDAAyD;IACzD,+EAA+E;IAC/E,6DAA6D;IAC7D,yEAAyE;IACzE,SAAS;IACT,EAAE;IACF,2EAA2E;IAC3E,uEAAuE;IACvE,wEAAwE;IACxE,8EAA8E;IAC9E,qDAAqD;IACrD,EAAE;IACF,2EAA2E;IAC3E,2EAA2E;IAC3E,6EAA6E;IAC7E,uDAAuD;IACvD,IAAI,QAAQ,GAAG,KAAK,CAAC;IAErB,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,KAAK,CAAC,6BAA6B,EAAE,CAAC,oBAAoB,CAAC,CAAC,CAAC;QAC1E,QAAQ,GAAG,IAAI,CAAC;QAEhB,2EAA2E;QAC3E,EAAE;QACF,8EAA8E;QAC9E,8EAA8E;QAC9E,8EAA8E;QAC9E,4EAA4E;QAC5E,yDAAyD;QACzD,EAAE;QACF,0EAA0E;QAC1E,+EAA+E;QAC/E,8EAA8E;QAC9E,8BAA8B;QAC9B,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;KASlB,CAAC,CAAC;QAEH,2EAA2E;QAC3E,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;;;KAYlB,CAAC,CAAC;QAEH,cAAc;QACd,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;KAmBlB,CAAC,CAAC;QAEH,qBAAqB;QACrB,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;;;KAwBlB,CAAC,CAAC;QAEH,kGAAkG;QAClG,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;KAelB,CAAC,CAAC;QAEH,2FAA2F;QAC3F,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;KAyBlB,CAAC,CAAC;QAEH,kEAAkE;QAClE,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;KASlB,CAAC,CAAC;QAEH,qEAAqE;QACrE,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;KAelB,CAAC,CAAC;QAEH,6CAA6C;QAC7C,EAAE;QACF,0EAA0E;QAC1E,0EAA0E;QAC1E,2EAA2E;QAC3E,iEAAiE;QACjE,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,wCAAwC;QACxC,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,sBAAsB;QACtB,EAAE;QACF,6EAA6E;QAC7E,yCAAyC;QACzC,EAAE;QACF,gFAAgF;QAChF,8EAA8E;QAC9E,8EAA8E;QAC9E,6EAA6E;QAC7E,gEAAgE;QAChE,iEAAiE;QACjE,+EAA+E;QAC/E,EAAE;QACF,2EAA2E;QAC3E,6BAA6B;QAC7B,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;KAsBlB,CAAC,CAAC;QAEH,qEAAqE;QACrE,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,4DAA4D;QAC5D,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,6EAA6E;QAC7E,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,kDAAkD;QAClD,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;;;;KAalB,CAAC,CAAC;QAEH,0DAA0D;QAC1D,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;;;;KAalB,CAAC,CAAC;QAEH,kFAAkF;QAClF,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,oCAAoC;QACpC,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,kCAAkC;QAClC,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,kDAAkD;QAClD,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,4DAA4D;QAC5D,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,2EAA2E;QAC3E,4EAA4E;QAC5E,yEAAyE;QACzE,4EAA4E;QAC5E,wEAAwE;QACxE,sEAAsE;QACtE,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,4DAA4D;QAC5D,6EAA6E;QAC7E,4EAA4E;QAC5E,2EAA2E;QAC3E,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,iEAAiE;QACjE,wEAAwE;QACxE,0EAA0E;QAC1E,2CAA2C;QAC3C,EAAE;QACF,4EAA4E;QAC5E,sEAAsE;QACtE,4EAA4E;QAC5E,oDAAoD;QACpD,EAAE;QACF,wEAAwE;QACxE,0EAA0E;QAC1E,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;KAOlB,CAAC,CAAC;QAEH,8DAA8D;QAC9D,6EAA6E;QAC7E,8EAA8E;QAC9E,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,mDAAmD;QACnD,6EAA6E;QAC7E,4EAA4E;QAC5E,6EAA6E;QAC7E,wEAAwE;QACxE,+EAA+E;QAC/E,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;KAgBlB,CAAC,CAAC;QAEH,iEAAiE;QACjE,wEAAwE;QACxE,4EAA4E;QAC5E,4EAA4E;QAC5E,8EAA8E;QAC9E,2EAA2E;QAC3E,yEAAyE;QACzE,2EAA2E;QAC3E,oBAAoB;QACpB,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;KAgBlB,CAAC,CAAC;QAEH,iCAAiC;QACjC,MAAM,MAAM,CAAC,KAAK,CAAC,6EAA6E,CAAC,CAAC;QAClG,MAAM,MAAM,CAAC,KAAK,CAAC,gEAAgE,CAAC,CAAC;QACrF,MAAM,MAAM,CAAC,KAAK,CAAC,2EAA2E,CAAC,CAAC;QAChG,MAAM,MAAM,CAAC,KAAK,CAAC,wEAAwE,CAAC,CAAC;QAC7F,MAAM,MAAM,CAAC,KAAK,CAAC,kFAAkF,CAAC,CAAC;QACvG,MAAM,MAAM,CAAC,KAAK,CAAC,2FAA2F,CAAC,CAAC;QAChH,qFAAqF;QACrF,MAAM,MAAM,CAAC,KAAK,CAAC,sHAAsH,CAAC,CAAC;QAC3I,oCAAoC;QACpC,MAAM,MAAM,CAAC,KAAK,CAAC,2FAA2F,CAAC,CAAC;QAChH,MAAM,MAAM,CAAC,KAAK,CAAC,uFAAuF,CAAC,CAAC;QAC5G,MAAM,MAAM,CAAC,KAAK,CAAC,yFAAyF,CAAC,CAAC;QAC9G,MAAM,MAAM,CAAC,KAAK,CAAC,4EAA4E,CAAC,CAAC;QACjG,MAAM,MAAM,CAAC,KAAK,CAAC,wFAAwF,CAAC,CAAC;QAC7G,MAAM,MAAM,CAAC,KAAK,CAAC,iGAAiG,CAAC,CAAC;QAEtH,6BAA6B;QAC7B,MAAM,MAAM,CAAC,KAAK,CAAC,mFAAmF,CAAC,CAAC;QACxG,MAAM,MAAM,CAAC,KAAK,CAAC,kFAAkF,CAAC,CAAC;QACvG,MAAM,MAAM,CAAC,KAAK,CAAC,wEAAwE,CAAC,CAAC;QAC7F,MAAM,MAAM,CAAC,KAAK,CAAC,gFAAgF,CAAC,CAAC;QAErG,uBAAuB;QACvB,MAAM,MAAM,CAAC,KAAK,CAAC,sEAAsE,CAAC,CAAC;QAC3F,MAAM,MAAM,CAAC,KAAK,CAAC,8FAA8F,CAAC,CAAC;QAEnH,4BAA4B;QAC5B,MAAM,MAAM,CAAC,KAAK,CAAC,sFAAsF,CAAC,CAAC;QAC3G,MAAM,MAAM,CAAC,KAAK,CAAC,gFAAgF,CAAC,CAAC;QACrG,MAAM,MAAM,CAAC,KAAK,CAAC,+FAA+F,CAAC,CAAC;QACpH,iFAAiF;QACjF,MAAM,MAAM,CAAC,KAAK,CAAC,yHAAyH,CAAC,CAAC;QAC9I,+EAA+E;QAC/E,0EAA0E;QAC1E,MAAM,MAAM,CAAC,KAAK,CAAC,gHAAgH,CAAC,CAAC;QAErI,kEAAkE;QAClE,MAAM,MAAM,CAAC,KAAK,CAAC;;;;;;;;;;KAUlB,CAAC,CAAC;QAEH,OAAO,CAAC,GAAG,CAAC,0CAA0C,CAAC,CAAC;IAC1D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,8BAA8B,EAAE,KAAK,CAAC,CAAC;QACrD,MAAM,KAAK,CAAC;IACd,CAAC;YAAS,CAAC;QACT,IAAI,QAAQ,EAAE,CAAC;YACb,qEAAqE;YACrE,8DAA8D;YAC9D,MAAM,MAAM;iBACT,KAAK,CAAC,+BAA+B,EAAE,CAAC,oBAAoB,CAAC,CAAC;iBAC9D,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAC5B,CAAC;QACD,MAAM,CAAC,OAAO,EAAE,CAAC;IACnB,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"database.js","sourceRoot":"","sources":["../../src/lib/database.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Launchpad: the page a visitor sees when a link cannot open the app.
3
+ *
4
+ * A short link to app-only content has nowhere good to send a desktop visitor,
5
+ * and a phone without the app installed otherwise gets a bare store redirect.
6
+ * This page is the alternative: the link's title, description and image, the
7
+ * app's icon and name, store buttons, a QR code so a desktop visitor can finish
8
+ * on their phone, and — when the app has a URI scheme — an "Open in app" button.
9
+ *
10
+ * Three rules shape everything below:
11
+ *
12
+ * 1. **Never add a hop to a link that already works.** The page is served by
13
+ * default only when a link has no web destination at any level. Serving it
14
+ * in front of a working destination is a per-workspace or per-link choice.
15
+ * 2. **Never navigate on the visitor's behalf.** No timers, no automatic scheme
16
+ * attempts. A forced `myapp://` open with the app absent produces an OS error
17
+ * alert and a dead tab; a button the visitor taps produces neither.
18
+ * 3. **Nothing on the page is authored by the visitor's target.** Every value
19
+ * is escaped; the one exception, `heroHtml`, is rendered by the host
20
+ * application from its own templates and is documented as such below.
21
+ *
22
+ * The page is search-engine invisible (`noindex`, and the route sends
23
+ * `X-Robots-Tag` and `Cache-Control: no-store`), because a redirect host that
24
+ * indexes one page per short link is a thin-content spam surface.
25
+ */
26
+ export type LaunchpadDesktopMode = 'no-destination' | 'always' | 'off';
27
+ export type LaunchpadMobileMode = 'store' | 'page';
28
+ export type LaunchpadLinkMode = 'inherit' | 'on' | 'off';
29
+ /**
30
+ * Per-workspace configuration, read from `organizations.settings.launchpad`.
31
+ * Every field is optional; an absent object means "the defaults", which are
32
+ * today's behaviour everywhere except the no-destination desktop case.
33
+ */
34
+ export interface LaunchpadSettings {
35
+ /** When a desktop visitor gets the page. Default `no-destination`. */
36
+ desktop?: LaunchpadDesktopMode;
37
+ /**
38
+ * What a mobile visitor gets when the link has no Universal Link / App Link
39
+ * for their platform. Default `store`: today's behaviour — a redirect to the
40
+ * store, or the scheme interstitial when the link has a URI scheme. `page`
41
+ * serves the launchpad page instead, with "Open in app" as a button the
42
+ * visitor taps rather than a navigation the page performs.
43
+ */
44
+ mobile?: LaunchpadMobileMode;
45
+ /** Shown in the page header, and as the `<title>` fallback. */
46
+ appName?: string;
47
+ /** Absolute https URL of the app icon, shown at 64×64. */
48
+ appIconUrl?: string;
49
+ /** `#rrggbb`. Drives the primary button. Anything else is ignored. */
50
+ accentColor?: string;
51
+ /** Opaque to Core: a host application may key its own page templates on it. */
52
+ templateId?: string;
53
+ }
54
+ export interface LaunchpadTheme {
55
+ appName?: string;
56
+ appIconUrl?: string;
57
+ accentColor?: string;
58
+ }
59
+ /**
60
+ * What the page shows. Produced by `defaultLaunchpadContent()` from the link
61
+ * row, or by a host application's `resolveContent` hook when it has richer
62
+ * material (a rendered share card, a template).
63
+ */
64
+ export interface LaunchpadContent {
65
+ title: string;
66
+ description: string;
67
+ /** Hero image, absolute http(s) URL. Also used for `og:image`. */
68
+ imageUrl: string | null;
69
+ /**
70
+ * Pre-rendered HTML for the hero region, inserted **without escaping**.
71
+ *
72
+ * Trust boundary: this must be produced by the host application from its own
73
+ * templates, never from visitor or end-user input. The page's CSP allows no
74
+ * script without the per-response nonce, which the host does not know, so a
75
+ * mistake here cannot execute — but it can still deface. Leave it unset unless
76
+ * you render it yourself.
77
+ */
78
+ heroHtml?: string;
79
+ theme: LaunchpadTheme;
80
+ }
81
+ /** The subset of a `links` row the default content is built from. */
82
+ export interface LaunchpadLinkFields {
83
+ title?: string | null;
84
+ description?: string | null;
85
+ og_title?: string | null;
86
+ og_description?: string | null;
87
+ og_image_url?: string | null;
88
+ }
89
+ /**
90
+ * Read `settings.launchpad` off a workspace settings JSON blob, tolerating any
91
+ * shape. Settings are written by whatever administers the workspace, not by
92
+ * this package, so nothing here is trusted: unknown modes fall back to the
93
+ * default, a bad colour is dropped, and image URLs must be http(s).
94
+ */
95
+ export declare function readLaunchpadSettings(orgSettings: unknown): LaunchpadSettings;
96
+ /** Normalise `links.launchpad_mode`, which a cached row from before the column existed may lack. */
97
+ export declare function readLaunchpadLinkMode(value: unknown): LaunchpadLinkMode;
98
+ /**
99
+ * Whether a desktop visitor gets the page.
100
+ *
101
+ * The link's own mode wins outright. Otherwise the workspace decides, and the
102
+ * default serves the page only where the alternative is nothing at all.
103
+ */
104
+ export declare function shouldServeLaunchpadOnDesktop(input: {
105
+ hasWebDestination: boolean;
106
+ orgMode?: LaunchpadDesktopMode;
107
+ linkMode?: LaunchpadLinkMode;
108
+ }): boolean;
109
+ /**
110
+ * Whether a mobile visitor gets the page.
111
+ *
112
+ * Only when the workspace chose `page`, and never for a platform whose link
113
+ * carries a Universal Link / App Link: the OS resolves the installed case
114
+ * before this server sees the click, and the 302 to that URL must stay so it
115
+ * can. A link's `on` does not force the page onto a workspace that chose
116
+ * `store` — that would be the extra hop other products had to add a skip flag
117
+ * for — but `off` still opts a link out.
118
+ */
119
+ export declare function shouldServeLaunchpadOnMobile(input: {
120
+ mobileMode?: LaunchpadMobileMode;
121
+ linkMode?: LaunchpadLinkMode;
122
+ hasAppOpenPath: boolean;
123
+ }): boolean;
124
+ /** Content from the link row alone: what any deployment can show with no extra machinery. */
125
+ export declare function defaultLaunchpadContent(link: LaunchpadLinkFields, settings: LaunchpadSettings): LaunchpadContent;
126
+ /** A fresh CSP nonce per response. */
127
+ export declare function createLaunchpadNonce(): string;
128
+ /**
129
+ * The page's content-security policy.
130
+ *
131
+ * Scripts run only with this response's nonce. Inline `style` attributes are
132
+ * allowed because a host-rendered hero region carries them, and a nonce on
133
+ * `style-src` would make browsers ignore `'unsafe-inline'` — so the nonce
134
+ * guards scripts alone. `connect-src` admits the beacon endpoint's origin when
135
+ * one is configured; `sendBeacon` is governed by it.
136
+ */
137
+ export declare function launchpadContentSecurityPolicy(nonce: string, beaconUrl?: string): string;
138
+ export interface LaunchpadPageContext {
139
+ content: LaunchpadContent;
140
+ /** The link's id; the QR code is fetched from `/api/links/{id}/qr`. */
141
+ linkId: string;
142
+ /** Absolute URL of this page, for `og:url`. */
143
+ pageUrl: string;
144
+ iosUrl: string | null;
145
+ androidUrl: string | null;
146
+ /**
147
+ * The link's web destination, when it has one. Rendered as a plain
148
+ * "Continue on the web" link so the page is never a dead end: a desktop
149
+ * visitor in `always` mode still has the destination the 302 would have
150
+ * given them, and a phone visitor inside an in-app browser keeps the path
151
+ * that lets a Universal Link fire on the next hop.
152
+ */
153
+ webUrl?: string | null;
154
+ /**
155
+ * The app's URI-scheme URL for this link, or null. When set, an "Open in app"
156
+ * button is rendered whose only job is to navigate to it on tap. The URL
157
+ * fragment of the page is appended on tap, so a key that lives only in the
158
+ * fragment survives without ever reaching the server.
159
+ */
160
+ schemeUrl: string | null;
161
+ /** Show the QR block. Meant for desktop; a phone scanning itself is no use. */
162
+ showQr: boolean;
163
+ nonce: string;
164
+ /**
165
+ * When set, the page reports `view` on load and `cta_ios` / `cta_android` /
166
+ * `cta_open` / `cta_web` on the matching tap via `navigator.sendBeacon`, as a JSON body
167
+ * `{ linkId, event }`. No cookies, no identifiers. Absent by default; the
168
+ * endpoint is the host application's to provide.
169
+ */
170
+ beaconUrl?: string;
171
+ }
172
+ /**
173
+ * Render the full HTML document. Pure: same context, same output (the nonce
174
+ * is an input). Every string from `content`, the URLs and the theme is
175
+ * escaped; `content.heroHtml` is the documented exception.
176
+ */
177
+ export declare function renderLaunchpadPage(ctx: LaunchpadPageContext): string;
178
+ //# sourceMappingURL=launchpad.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"launchpad.d.ts","sourceRoot":"","sources":["../../src/lib/launchpad.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,262 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { escapeHtml, safeHref } from './link-safety.js';
3
+ const DESKTOP_MODES = ['no-destination', 'always', 'off'];
4
+ const MOBILE_MODES = ['store', 'page'];
5
+ const LINK_MODES = ['inherit', 'on', 'off'];
6
+ const HEX_COLOR = /^#[0-9a-f]{6}$/i;
7
+ const asString = (v, max) => typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : undefined;
8
+ /**
9
+ * Read `settings.launchpad` off a workspace settings JSON blob, tolerating any
10
+ * shape. Settings are written by whatever administers the workspace, not by
11
+ * this package, so nothing here is trusted: unknown modes fall back to the
12
+ * default, a bad colour is dropped, and image URLs must be http(s).
13
+ */
14
+ export function readLaunchpadSettings(orgSettings) {
15
+ const raw = orgSettings && typeof orgSettings === 'object' && orgSettings.launchpad;
16
+ if (!raw || typeof raw !== 'object')
17
+ return {};
18
+ const r = raw;
19
+ const desktop = DESKTOP_MODES.find((m) => m === r.desktop);
20
+ const mobile = MOBILE_MODES.find((m) => m === r.mobile);
21
+ const accentColor = asString(r.accentColor, 7);
22
+ const appIconUrl = asString(r.appIconUrl, 2048);
23
+ return {
24
+ ...(desktop ? { desktop } : {}),
25
+ ...(mobile ? { mobile } : {}),
26
+ ...(asString(r.appName, 60) ? { appName: asString(r.appName, 60) } : {}),
27
+ ...(appIconUrl && safeHref(appIconUrl) ? { appIconUrl } : {}),
28
+ ...(accentColor && HEX_COLOR.test(accentColor) ? { accentColor } : {}),
29
+ ...(asString(r.templateId, 64) ? { templateId: asString(r.templateId, 64) } : {}),
30
+ };
31
+ }
32
+ /** Normalise `links.launchpad_mode`, which a cached row from before the column existed may lack. */
33
+ export function readLaunchpadLinkMode(value) {
34
+ return LINK_MODES.find((m) => m === value) ?? 'inherit';
35
+ }
36
+ /**
37
+ * Whether a desktop visitor gets the page.
38
+ *
39
+ * The link's own mode wins outright. Otherwise the workspace decides, and the
40
+ * default serves the page only where the alternative is nothing at all.
41
+ */
42
+ export function shouldServeLaunchpadOnDesktop(input) {
43
+ const linkMode = input.linkMode ?? 'inherit';
44
+ if (linkMode === 'off')
45
+ return false;
46
+ if (linkMode === 'on')
47
+ return true;
48
+ switch (input.orgMode ?? 'no-destination') {
49
+ case 'always':
50
+ return true;
51
+ case 'off':
52
+ return false;
53
+ default:
54
+ return !input.hasWebDestination;
55
+ }
56
+ }
57
+ /**
58
+ * Whether a mobile visitor gets the page.
59
+ *
60
+ * Only when the workspace chose `page`, and never for a platform whose link
61
+ * carries a Universal Link / App Link: the OS resolves the installed case
62
+ * before this server sees the click, and the 302 to that URL must stay so it
63
+ * can. A link's `on` does not force the page onto a workspace that chose
64
+ * `store` — that would be the extra hop other products had to add a skip flag
65
+ * for — but `off` still opts a link out.
66
+ */
67
+ export function shouldServeLaunchpadOnMobile(input) {
68
+ if ((input.linkMode ?? 'inherit') === 'off')
69
+ return false;
70
+ if ((input.mobileMode ?? 'store') !== 'page')
71
+ return false;
72
+ return !input.hasAppOpenPath;
73
+ }
74
+ /** Content from the link row alone: what any deployment can show with no extra machinery. */
75
+ export function defaultLaunchpadContent(link, settings) {
76
+ const image = link.og_image_url && safeHref(link.og_image_url) ? link.og_image_url : null;
77
+ return {
78
+ title: link.og_title || link.title || settings.appName || 'Open in the app',
79
+ description: link.og_description || link.description || '',
80
+ imageUrl: image,
81
+ theme: {
82
+ ...(settings.appName ? { appName: settings.appName } : {}),
83
+ ...(settings.appIconUrl ? { appIconUrl: settings.appIconUrl } : {}),
84
+ ...(settings.accentColor ? { accentColor: settings.accentColor } : {}),
85
+ },
86
+ };
87
+ }
88
+ /** WCAG relative luminance above ~0.5 reads better with dark text on it. */
89
+ function isLightColor(hex) {
90
+ const channel = (i) => {
91
+ const c = parseInt(hex.slice(i, i + 2), 16) / 255;
92
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
93
+ };
94
+ return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5) > 0.5;
95
+ }
96
+ /** A fresh CSP nonce per response. */
97
+ export function createLaunchpadNonce() {
98
+ return randomBytes(16).toString('base64');
99
+ }
100
+ /**
101
+ * The page's content-security policy.
102
+ *
103
+ * Scripts run only with this response's nonce. Inline `style` attributes are
104
+ * allowed because a host-rendered hero region carries them, and a nonce on
105
+ * `style-src` would make browsers ignore `'unsafe-inline'` — so the nonce
106
+ * guards scripts alone. `connect-src` admits the beacon endpoint's origin when
107
+ * one is configured; `sendBeacon` is governed by it.
108
+ */
109
+ export function launchpadContentSecurityPolicy(nonce, beaconUrl) {
110
+ const connect = new Set(["'self'"]);
111
+ if (beaconUrl) {
112
+ try {
113
+ connect.add(new URL(beaconUrl).origin);
114
+ }
115
+ catch {
116
+ // Relative beacon URL — 'self' covers it.
117
+ }
118
+ }
119
+ return [
120
+ "default-src 'none'",
121
+ "img-src 'self' https: data:",
122
+ "style-src 'unsafe-inline'",
123
+ `script-src 'nonce-${nonce}'`,
124
+ `connect-src ${[...connect].join(' ')}`,
125
+ "base-uri 'none'",
126
+ "form-action 'none'",
127
+ ].join('; ');
128
+ }
129
+ const STYLES = `
130
+ :root { --lp-ink: #16181d; --lp-muted: #5f6673; --lp-bg: #ffffff; --lp-surface: #f4f5f7; --lp-line: #e2e5ea; --lp-accent: #2563eb; --lp-accent-ink: #ffffff; }
131
+ @media (prefers-color-scheme: dark) { :root { --lp-ink: #eef0f3; --lp-muted: #a3abb8; --lp-bg: #121418; --lp-surface: #1c1f25; --lp-line: #2b3038; } }
132
+ * { box-sizing: border-box; }
133
+ body { margin: 0; background: var(--lp-bg); color: var(--lp-ink); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.5; -webkit-font-smoothing: antialiased; }
134
+ .lp { max-width: 40rem; margin: 0 auto; padding: 2rem 1.25rem 3rem; display: flex; flex-direction: column; gap: 1.5rem; }
135
+ .lp-app { display: flex; align-items: center; gap: 0.75rem; min-height: 1rem; }
136
+ .lp-app img { width: 48px; height: 48px; border-radius: 12px; object-fit: cover; background: var(--lp-surface); }
137
+ .lp-app span { font-weight: 600; font-size: 1rem; }
138
+ .lp-hero img { display: block; width: 100%; max-height: 22rem; object-fit: cover; border-radius: 14px; background: var(--lp-surface); margin-bottom: 1rem; }
139
+ .lp-hero h1 { font-size: 1.75rem; line-height: 1.2; margin: 0 0 0.5rem; text-wrap: balance; }
140
+ .lp-hero p { margin: 0; color: var(--lp-muted); font-size: 1.05rem; }
141
+ .lp-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; }
142
+ .lp-btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.8rem 1.25rem; border-radius: 10px; font-size: 0.95rem; font-weight: 600; text-decoration: none; border: 1px solid var(--lp-line); background: var(--lp-surface); color: var(--lp-ink); }
143
+ .lp-btn-primary { background: var(--lp-accent); border-color: var(--lp-accent); color: var(--lp-accent-ink); }
144
+ .lp-btn:focus-visible { outline: 2px solid var(--lp-accent); outline-offset: 2px; }
145
+ .lp-web { margin: -0.5rem 0 0; font-size: 0.95rem; }
146
+ .lp-web a { color: var(--lp-muted); text-decoration: underline; text-underline-offset: 3px; }
147
+ .lp-web a:hover { color: var(--lp-ink); }
148
+ .lp-qr { display: flex; align-items: center; gap: 1.25rem; padding: 1rem; border: 1px solid var(--lp-line); border-radius: 14px; background: var(--lp-surface); }
149
+ .lp-qr img { width: 132px; height: 132px; border-radius: 8px; background: #fff; flex: none; }
150
+ .lp-qr strong { display: block; font-size: 1rem; margin-bottom: 0.2rem; }
151
+ .lp-qr p { margin: 0; color: var(--lp-muted); font-size: 0.9rem; }
152
+ @media (max-width: 480px) { .lp-qr { flex-direction: column; text-align: center; } .lp-hero h1 { font-size: 1.5rem; } }
153
+ `;
154
+ /** Only shipped when a button or a beacon needs it; a page with neither has no script at all. */
155
+ const SCRIPT = `
156
+ (function () {
157
+ var body = document.body;
158
+ var open = document.getElementById('lp-open');
159
+ if (open) {
160
+ open.addEventListener('click', function () {
161
+ open.href = open.getAttribute('data-scheme') + (window.location.hash || '');
162
+ });
163
+ }
164
+ var beacon = body.getAttribute('data-beacon');
165
+ if (beacon && navigator.sendBeacon) {
166
+ var linkId = body.getAttribute('data-link-id');
167
+ var send = function (event) {
168
+ try { navigator.sendBeacon(beacon, JSON.stringify({ linkId: linkId, event: event })); } catch (e) {}
169
+ };
170
+ send('view');
171
+ var ctas = document.querySelectorAll('[data-lp-cta]');
172
+ for (var i = 0; i < ctas.length; i++) {
173
+ ctas[i].addEventListener('click', function (e) { send(e.currentTarget.getAttribute('data-lp-cta')); });
174
+ }
175
+ }
176
+ })();
177
+ `;
178
+ /**
179
+ * Render the full HTML document. Pure: same context, same output (the nonce
180
+ * is an input). Every string from `content`, the URLs and the theme is
181
+ * escaped; `content.heroHtml` is the documented exception.
182
+ */
183
+ export function renderLaunchpadPage(ctx) {
184
+ const content = ctx.content;
185
+ const theme = content.theme;
186
+ const title = escapeHtml(content.title);
187
+ const description = escapeHtml(content.description);
188
+ const imageUrl = content.imageUrl && safeHref(content.imageUrl) ? escapeHtml(content.imageUrl) : null;
189
+ const iconUrl = theme.appIconUrl && safeHref(theme.appIconUrl) ? escapeHtml(theme.appIconUrl) : null;
190
+ const iosUrl = ctx.iosUrl && safeHref(ctx.iosUrl) ? escapeHtml(ctx.iosUrl) : null;
191
+ const androidUrl = ctx.androidUrl && safeHref(ctx.androidUrl) ? escapeHtml(ctx.androidUrl) : null;
192
+ const accent = theme.accentColor && HEX_COLOR.test(theme.accentColor) ? theme.accentColor : null;
193
+ const accentInk = accent && isLightColor(accent) ? '#16181d' : '#ffffff';
194
+ const nonce = escapeHtml(ctx.nonce);
195
+ const head = [
196
+ `<title>${title}</title>`,
197
+ description ? `<meta name="description" content="${description}">` : '',
198
+ `<meta property="og:type" content="website">`,
199
+ `<meta property="og:url" content="${escapeHtml(ctx.pageUrl)}">`,
200
+ `<meta property="og:title" content="${title}">`,
201
+ description ? `<meta property="og:description" content="${description}">` : '',
202
+ imageUrl ? `<meta property="og:image" content="${imageUrl}">` : '',
203
+ `<meta name="twitter:card" content="${imageUrl ? 'summary_large_image' : 'summary'}">`,
204
+ ]
205
+ .filter(Boolean)
206
+ .join('\n');
207
+ const header = iconUrl || theme.appName
208
+ ? `<header class="lp-app">${iconUrl ? `<img src="${iconUrl}" alt="" width="48" height="48">` : ''}${theme.appName ? `<span>${escapeHtml(theme.appName)}</span>` : ''}</header>`
209
+ : '';
210
+ const hero = content.heroHtml
211
+ ? `<section class="lp-hero">${content.heroHtml}</section>`
212
+ : `<section class="lp-hero">${imageUrl ? `<img src="${imageUrl}" alt="">` : ''}<h1>${title}</h1>${description ? `<p>${description}</p>` : ''}</section>`;
213
+ const buttons = [
214
+ ctx.schemeUrl
215
+ ? `<a class="lp-btn lp-btn-primary" id="lp-open" data-lp-cta="cta_open" data-scheme="${escapeHtml(ctx.schemeUrl)}" href="${escapeHtml(ctx.schemeUrl)}">Open in app</a>`
216
+ : '',
217
+ iosUrl
218
+ ? `<a class="lp-btn${ctx.schemeUrl ? '' : ' lp-btn-primary'}" data-lp-cta="cta_ios" href="${iosUrl}">Download on the App Store</a>`
219
+ : '',
220
+ androidUrl
221
+ ? `<a class="lp-btn${ctx.schemeUrl || iosUrl ? '' : ' lp-btn-primary'}" data-lp-cta="cta_android" href="${androidUrl}">Get it on Google Play</a>`
222
+ : '',
223
+ ]
224
+ .filter(Boolean)
225
+ .join('');
226
+ const webUrl = ctx.webUrl && safeHref(ctx.webUrl) ? escapeHtml(ctx.webUrl) : null;
227
+ const actions = buttons ? `<section class="lp-actions">${buttons}</section>` : '';
228
+ const web = webUrl
229
+ ? `<p class="lp-web"><a data-lp-cta="cta_web" href="${webUrl}">Continue on the web</a></p>`
230
+ : '';
231
+ const qr = ctx.showQr
232
+ ? `<section class="lp-qr"><img src="/api/links/${escapeHtml(ctx.linkId)}/qr?format=svg&amp;size=264" alt="QR code for this link" width="132" height="132"><div><strong>Scan to open on your phone</strong><p>Point your phone's camera at the code to open this link there.</p></div></section>`
233
+ : '';
234
+ const needsScript = Boolean(ctx.schemeUrl || ctx.beaconUrl);
235
+ const bodyAttrs = [
236
+ `data-link-id="${escapeHtml(ctx.linkId)}"`,
237
+ ctx.beaconUrl ? `data-beacon="${escapeHtml(ctx.beaconUrl)}"` : '',
238
+ ]
239
+ .filter(Boolean)
240
+ .join(' ');
241
+ return `<!DOCTYPE html>
242
+ <html lang="en">
243
+ <head>
244
+ <meta charset="utf-8">
245
+ <meta name="viewport" content="width=device-width, initial-scale=1">
246
+ <meta name="robots" content="noindex, nofollow">
247
+ ${head}
248
+ <style>${STYLES}${accent ? `\n :root { --lp-accent: ${accent}; --lp-accent-ink: ${accentInk}; }` : ''}</style>
249
+ </head>
250
+ <body ${bodyAttrs}>
251
+ <main class="lp">
252
+ ${header}
253
+ ${hero}
254
+ ${actions}
255
+ ${web}
256
+ ${qr}
257
+ </main>
258
+ ${needsScript ? `<script nonce="${nonce}">${SCRIPT}</script>` : ''}
259
+ </body>
260
+ </html>`;
261
+ }
262
+ //# sourceMappingURL=launchpad.js.map