Skip to content

feat(config): set instance Launchpad defaults in linkforty.config.json - #90

Merged
onamfc merged 1 commit into
mainfrom
feat/launchpad-config
Oct 28, 2026
Merged

onamfc merged 1 commit into
mainfrom
feat/launchpad-config

Conversation

@onamfc

@onamfc onamfc commented Oct 28, 2026

Copy link
Copy Markdown
Member

Description

The Launchpad page could only be configured through an organization's settings.launchpad. Links created through Core's own API have no organization, so a self-hosted instance couldn't set its app name, icon, website or colors, or turn on the desktop always and mobile page modes, without editing the database by hand.

linkforty.config.json, added in 1.42.0, now takes a launchpad section. It's the instance's default for every link, including links with no organization. An organization's settings.launchpad overrides it field by field.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • ✅ Test addition or update
  • 📝 Documentation update

Configuring it

{
  "launchpad": {
    "desktop": "no-destination",
    "mobile": "store",
    "appName": "Example App",
    "appIconUrl": "https://example.com/icon.png",
    "appWebsiteUrl": "https://example.com/",
    "accentColor": "#0f766e",
    "backgroundColor": "#101418"
  }
}

Which value applies, per field: a link's launchpadMode wins over the organization's settings.launchpad, which wins over the config file's launchpad, which wins over the built-in defaults.

Why field by field: notFound replaces as a whole, because it's one small page. Launchpad settings are routinely partial: an organization often sets just a color or a mode. Replacing wholesale would drop the instance's app name and icon for those organizations. Merging per field keeps them. An organization value that fails validation is ignored rather than erasing a valid instance value. The docs spell out the difference from notFound.

Changes Made

  • src/lib/config.ts: a strict launchpad schema with the same limits Core already applies to organization settings: mode enums, appName up to 60 characters, http(s) URLs, and six-digit hex colors (the page mixes them to build its palette).
    • templateId is rejected: it names a host-rendered template and only makes sense per organization.
    • Unknown keys stop startup with the setting named, as for notFound.
  • src/lib/launchpad.ts: mergeLaunchpadSettings(defaults, orgSettings) is the single place the merge happens.
  • src/routes/redirect.ts: RedirectRouteOptions.launchpad.defaults. Both places that read the Launchpad settings (desktop and mobile) use the merged settings, and resolveContent receives them.
  • src/index.ts: createServer() passes config.launchpad as launchpad.defaults, keeping any resolveContent / beaconUrl passed in code. Options in code still take precedence over the file.
  • Docs: a Launchpad table in DOCKER.md's "Configuration file" section, the README Launchpad section, and a launchpad block in linkforty.config.example.json.

Testing

  • Tests pass locally (npm test): 628 passed. The database integration suites skip without DATABASE_URL.
  • New tests added for new functionality
  • Existing tests updated (if needed)
  • Type checking passes (npm run typecheck)

Test Coverage

  • No change without a config: the Launchpad output is compared with and without empty defaults, on desktop and iPhone, with and without organization settings. It's identical apart from the per-response nonce.
  • src/routes/redirect.launchpad.test.ts:
    • instance desktop: "always" and mobile: "page" serve the page;
    • an instance theme applies to a link with no organization;
    • an organization's appName overrides it while the instance's accentColor stays;
    • a link's off and an organization's off both still win over an instance always.
  • src/lib/launchpad.test.ts: mergeLaunchpadSettings equals readLaunchpadSettings when there are no defaults, merges per field, and invalid organization values never erase valid defaults.
  • src/lib/config.test.ts: every setting parses. A bad mode, a three-digit hex, a non-http(s) icon, templateId, an unknown key and an over-long name are each rejected with the setting named.
  • New src/index.config.test.ts: runs createServer() with only the database mocked. It checks that both config sections reach the pages a visitor sees, that hooks passed in code are kept, that code options win over the file, and that nothing changes without a config. This also covers the notFound wiring from 1.42.0.
  • Manual: the Docker entry script with "launchpad": { "accentColor": "teal" } exits 1 with launchpad.accentColor: must be a six-digit hex color such as #0f766e.
npm test

Checklist

  • My code follows the project's code style
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have used conventional commit messages (e.g., feat:, fix:)

Breaking Changes

None. Without a launchpad section the page behaves exactly as before, and a test checks that. Organization settings keep working unchanged.

The Launchpad page was configurable only through an organization's settings.launchpad. Links created through Core's own API have no organization, so a self-hosted instance could not set its app name, icon, website or colors, or turn on the desktop "always" and mobile "page" modes, without editing the database by hand.

linkforty.config.json now takes a launchpad section with the same fields (desktop, mobile, appName, appIconUrl, appWebsiteUrl, accentColor, backgroundColor), validated strictly at startup like notFound: an invalid value or unknown setting stops the server with the setting named. Colors must be six-digit hex because the page mixes them. templateId is not accepted there, since it names a host-rendered template and only makes sense per organization.

An organization's settings.launchpad overrides the instance's field by field (mergeLaunchpadSettings), not as a whole. Organizations routinely set one or two fields, and replacing the instance's settings would drop its app name and icon for them. An organization value that fails validation is ignored rather than erasing a valid instance value. A link's own launchpad_mode still wins over both. This differs from notFound, where an organization's settings replace the instance's, and the docs say so.

createServer() passes config.launchpad to the redirect routes as launchpad.defaults, keeping any resolveContent hook or beaconUrl passed in code; options in code still take precedence over the file. With no config the output is unchanged, which a test checks across desktop and mobile, with and without organization settings.

Adds src/index.config.test.ts, which runs createServer() with only the database mocked and checks that both config sections reach the pages a visitor sees. Documents the section in DOCKER.md, the README and linkforty.config.example.json.
@codecov

codecov Bot commented Oct 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@onamfc
onamfc merged commit fee5d6d into main Oct 28, 2026
14 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant