# Changelog All notable changes to LinkBreeze will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added - **Add/Edit-link drafts: typed entries survive closing the sheet** — half-filling the link dialog and closing it no longer loses the work: the form's field state (type, title, URL, description, thumbnail, popup body, CTA label, icon mode + pick, card style, section, /go/ slug, advanced disclosure) is debounce-written (300ms) to `sessionStorage` under one key per dialog target (create-per-page, edit-per-link), and reopening restores it behind a "Draft restored" chip with a one-click Discard back to defaults. The draft's lifetime is exactly the browser session by design: the tab closing clears it automatically, sign-out clears it explicitly (both sign-out forms), and a successful save discards it — a second browser tab never sees the first tab's draft. Known limits: freshly picked icon files can't persist (browser `File` objects don't serialize — the stored custom icon URL round-trips fine), and the advanced UTM/schedule date inputs stay per-open. Title/URL/description/thumbnail/CTA fields became fully controlled inputs to make restore exact (URL still displays the UTM-stripped form for edited links). No schema or server changes; drafts are read back defensively (versioned JSON, silent drop on parse failure). Verified live end-to-end on a throwaway instance: restore, discard, save-clears, edit-draft and sign-out-clears, plus the full 1754-test suite, lint/tsc/i18n gates. - **Markdown text cards: clickable `[text](url)` hyperlinks, numbered lists and `---` dividers (#93)** — popup card bodies now support inline links (http(s)-only hrefs — `javascript:`, `file:`, `data:` and any other scheme render as plain text; anchors carry `target="_blank"` + `rel="noopener noreferrer nofollow"`; markers like `**bold**` work inside link labels), numbered lists alongside bullet lists, and thematic-break dividers. The link dialog's popup-text field gained a live preview pane rendering the exact body the public popup will show, a markdown syntax hint bar, and a character counter; long popup bodies now scroll internally (max 60vh) instead of stretching the dialog. Same subset in the privacy-page renderer and the zero-JS public card renderer, with scheme-safety tests. - **A/B testing for links (#81)** — Any URL-type link can split its traffic between two destinations: enable the test in the link dialog, set variant B's URL plus optional labels and a traffic weight (default 50/50), and optionally box the run with start/end schedule fields (outside the window the link serves variant A only, clicks stay unlabeled). /go/:id assigns visitors deterministically from the existing cookieless visitor hash — sticky per visitor across visits, works with zero client-side JavaScript, and requires no cookie or storage a visitor could tamper with. The link detail page shows per-variant clicks, share, lift percentage and an honest confidence hint that refuses to call a winner below 100 clicks (binomial goodness-of-fit at ~95%), with Pause/Resume and one-click Promote A/B behind a confirmation — promoting pins the winner as the permanent destination and archives the test with its full history (labels, weights, per-variant click rows all kept; the short URL and QR keep working since they point at /go/:id). A new Settings → Data toggle ("A/B test auto-promotion", off by default) lets the instance promote significant winners automatically at 200+ clicks — same math as the UI hint, never mid-window, never below the sample floor. The clicks CSV export gains a variant column while a test is or was active; variants survive backup/restore, and a restored backup can never resurrect a finished test into a live split. Walkthrough with worked example numbers: [docs/ab-testing.md](docs/ab-testing.md). - **Native vCard "Save Contact" links with per-page Contact card settings** — the vCard link type no longer asks for a URL: it resolves to the page's own `/api/vcard/` route automatically, so "Add link → vCard → save" is all it takes. The generated card auto-fills from what the page already knows — title (FN/NICKNAME), bio (NOTE), avatar (embedded as PHOTO URI), and every social link (X-SOCIALPROFILE) — and a new **Settings → General → Contact card** section adds optional overrides (organization, job title, email, phone, address) that win over the auto values when set. The hardcoded `ORG:LinkBreeze` default is gone (ORG is emitted only when configured), the footer "Save contact" share button and the link type share the same builder, and validation accepts the URL-less form everywhere (server actions, REST API, migration wizard). Ships as migration `0035_vcard_settings` (five nullable columns, additive). - **Website (globe) social platform** — the social link picker now offers a "Website" entry with a globe icon (feather-style stroke set, same family as Instagram/Email/RSS), so a page can lead its social row with a link to the owner's main site before the platform icons. Recognized in existing saved `social_links` JSON, rendered by both the server (zero-JS public page) and the admin picker. - **Public REST API v1 (#4)** — LinkBreeze now has a programmatic surface: generate a single API key in Settings -> Security -> API access (plaintext shown once, sha256-hashed at rest, regenerate = instant rotate, revoke supported), then manage links (list/create/update/delete/reorder), the profile, and analytics through `/api/v1/*` via the `Authorization` header (HTTP Bearer scheme). Rate limited per key (120 req/min plus a per-IP pre-auth guard), demo-mode-blocked mutations, and the same URL-scheme validation as the admin UI. Every instance serves its own OpenAPI 3.1 document at `/api/v1/openapi.json` and an auth-gated HTML reference at `/api/v1/docs`. A router-drift test keeps the spec and the routes in exact agreement. The surface also covers single-link reads (`GET /links/:id`), sections CRUD + reorder, and bulk import (`POST /links/import`, 1-100 items with per-item results — one bad entry never aborts the batch). List endpoints support cursor pagination and sparse fieldsets; read endpoints send strong ETags, so pollers replaying `If-None-Match` get empty 304s until data changes. Rotation supports an opt-in migration grace (default 1 hour, configurable up to 24 — the previous key stays valid until the window closes or you revoke), and the admin card shows today's activity counts plus a live grace countdown. Response headers are machine-documented in the spec (X-RateLimit-*, Cache-Control, ETag, WWW-Authenticate, Retry-After), and the README cookbook (11 languages) includes conditional-GET and bulk-import examples. - **Multi-user accounts with customizable roles (#124)** — per-account public pages; admin create/enable/disable/delete with last-admin guard; editable roles (page limits, visibility, capability checks now enforced on every instance-level action); role-scoped admin surface; single-use invitation links (SHA-256-hashed, 7-day expiry) accepted at `/invite/`; append-only admin audit log at `/audit` with filters, cursor pagination and 90-day retention; per-role/per-user session lifetimes with action-based idle timeout and password re-entry for destructive admin actions. - **Generic OIDC / OAuth2 single sign-on (#124)** — sign in through any standard identity provider (Authentik, Keycloak, Authelia, Pocket ID…) via discovery: authorization-code + PKCE (S256), single-use expiring state, JIT provisioning, account linking, optional SSO-only mode. Group → role mapping (first-match, configurable claim) with the last-admin invariant; configurable username/email/name claims; env-only client secret; https-only issuers with the `LINKBREEZE_ALLOW_HTTP_ISSUER` escape hatch for trusted-LAN providers (which now actually works). The profile page gains a **Single sign-on** card: linked identities are listed per account, "Connect SSO" binds the current account through the standard authorize flow, and unlinking is self-service (two-click confirm, lockout-safe copy) with an `sso.unlinked` audit entry. ### Changed - **"Add link" dialog reworked into a left-docked sheet with a live card preview** — the centered modal becomes a mobile-first side sheet: below `md` it slides up as a bottom sheet (full width, rounded top, grabber handle), at `md+` it docks to the left at full height; the consumer passes the width (640px, 980px expanded). The form restructures into type-first chips (11 link types), full-width title/URL, paired fields (description + thumbnail, card style + section) and a single "Advanced" disclosure (slug, icon, UTM, A/B editor, schedule) with a sticky footer (Featured/Active toggles + Cancel/Save) under an aurora logo-mark header. An eye toggle expands the sheet to 980px with a live 1:1 card preview pane that renders through the same `buildLinkCardHtml`/`buildDividerHtml` the public page runs, with the page's real theme tokens resolved from the active theme; auto-icon mode resolves the preview's favicon from the DuckDuckGo icon service for the typed URL (same upstream as the server's favicon chain). On the links manager, section headers get a proper GripVertical drag handle and a violet folder badge, the page header stacks its three actions as icon-only buttons on phones, and mobile link rows collapse their inline pencil/trash/analytics/open icons into a ⋮ actions menu (Edit/Analytics/Open/Delete) so title, description and URL get the row's width back — descriptions now render on rows. The mobile bottom dock gains a Theme tab (Dashboard/Links on the left, raised Live-preview circle centered, Theme/Profile on the right), the new `advanced`, `preview` and `moreActions` strings land in all 11 locales, and the login form no longer sets `autoFocus` during SSR (a hydration attribute diff); focus moves through an effect instead. Failed saves keep every typed entry: the sheet's form opts out of React 19's automatic post-submit reset of uncontrolled inputs, so a server validation error (a missing CTA URL, say) shows inline while all fields keep their values — verified end-to-end against a throwaway instance replaying the exact text-popup repro. - **Admin sidebar rework** — the admin sidebar and mobile navigation are rebuilt: active sections carry an aurora highlight, the instance pages (Domains, Accounts, Audit log) fold under a Settings group whose items are App (the settings page), Domains, Accounts and Audit log, and the desktop sidebar collapses to a Frappe-style icon rail with tooltips (state persists per browser). The mobile bottom bar becomes a balanced dock — Dashboard and Links on the left, a raised aurora Live-preview circle in the center, Theme and Profile on the right — replacing the nine-slot tab bar whose labels overlapped on phones; the mobile top bar keeps a Settings gear whose drop-down carries the same shared group (App/Domains/Accounts/Audit) plus sign-out, so the header stays two slots (pages switcher + settings). An update-available chip in the sidebar footer deep-links to the updates card. - **Dependency updates (#131, #132, #133, #135)** — merged four Dependabot PRs: `systeminformation` 5.33.15 → 5.33.19 (fixes Dependabot alert #73, high), `@hono/node-server` 2.0.12 → 2.1.5 (fixes alert #72, medium), `actions/checkout` v6 → v7 across the workflows, and the #135 minor-and-patch group (`lucide-react` 1.61.0 → 1.65.0, `shadcn` 4.22.1 → 4.23.0, `@types/node` 26.6.5 → 26.6.6, `sql.js` 1.15.1 → 1.15.2). All CI build jobs green. - **joi override raised to ^17.13.9 (fixes Dependabot alert #75, high)** — the existing override range already allowed the patched release; the lockfile just predated it. Refreshed to 17.13.9 (transitive via `passkit-generator`, no API changes). The remaining two alerts need no action today: minimatch (#74, medium) is a dev-only eslint/ts-morph chain where v11 is a major upstream hasn't adopted, and deepmerge (#71, low) has no patched release yet. - **Dependency updates (#136, #137)** — merged two Dependabot PRs: the #136 minor-and-patch group with nine updates, headlined by `next` 16.4.3 → 16.4.5 — the November 2026 security release fixing seven moderate advisories, including cross-client response injection in self-hosted apps that forward fetched responses (the migration wizard's fetch path) and HttpOnly cookie exposure through internal response headers on edge routes; the group also carries `eslint-config-next` 16.4.3 → 16.4.5, `lucide-react` 1.65.0 → 1.70.0, `otpauth` 9.5.2 → 9.6.0, `vitest` 5.0.5 → 5.1.0, `@playwright/test` + `playwright-core` 1.64.1 → 1.65.0, and `tailwindcss` + `@tailwindcss/postcss` 4.3.3 → 4.3.4. #137 raises the directly declared `domhandler` 5.0.3 → 6.0.1 across its ESM-only major: the only in-repo usage is the type-only `AnyNode` import in the migration wizard, so runtime behavior is unchanged and cheerio keeps serving its own nested ^5 copy. All CI build and Lighthouse jobs green on both. ### Fixed - **Failed form saves no longer erase typed entries (React 19 auto-reset)** — forms submitted through `
` get every uncontrolled input reset by React 19 when the action resolves, success or failure, so a server-side validation error silently wiped whatever the operator had typed (reported on the new-page form and the login step, same root cause as the Add-link sheet fix folded into its entry above). Submission is now manual (`onSubmit` + `preventDefault`) on every affected surface — new-page creation, change-password, both 2FA code forms, the login and TOTP steps, all settings tabs plus the profile form (Save button and blur-autosave path alike), and the migration wizard's file picker — keeping native `required`/`pattern` bubbles and Enter-key submit while failed saves preserve typed values. Controlled-input forms (retention, import URL) and the public email capture (documented no-JS POST path) are intentionally unchanged. Verified by the full 1754-test suite plus a CI-equivalent empty-database run under Node 22, lint/tsc/i18n clean. - **Single sign-on card aligned with the profile page** — the SSO identities card rendered outside the profile column's centered `max-w-2xl` container (full-bleed width, glued to the form with no spacing); it now shares the same centered column and gap as every other profile card. On phones, the unlink-confirm cluster (warning text + Unlink/Cancel) wraps to its own full-width row inside the identity card instead of squeezing off-card, and the connect/unlink rows are wrap-safe with no horizontal overflow at 390px. - **Accounts listed once per account** — the accounts list joined every page a user owns, so an account with multiple public pages appeared once per page: tripled rows in the UI and duplicate React keys in the console ("Encountered two children with the same key"). The query now returns exactly one row per account, with the page slug resolved to the account's primary page (default page first, then alphabetical). - **Admin live preview iframe console warning** — the preview iframe carried `sandbox="allow-scripts … allow-same-origin"`, a combination that makes the sandbox escapable on same-origin content (already flagged as such in the #124 hardening) and logged a Chrome console warning on every open. The attribute is gone — the preview loads the instance's own page, so the sandbox never bought anything. - **Satoshi font glyph warnings cleaned** — the bundled Satoshi woff2 files carried stale glyph bounding boxes (an upstream Fontshare defect), so Chromium logged a "glyph bbox was incorrect; adjusting" console warning for every rendered weight. Bounds are recalculated in place with fontTools — identical outlines, corrected metadata — and the admin console is clean. - **Members can no longer write analytics/custom-code page fields (#124 hardening)** — `analyticsScript` and `customCss` are raw code rendered into the public page, but `updatePageAction` accepted them from any signed-in account. Under multi-user, a member setting a script on their own page could run same-origin code inside an admin's PreviewPane iframe (whose sandbox combination is escapable) — a member-to-admin XSS path. Both fields are now instance-admin-only at the action boundary; the settings UI already hid them from members, and members editing their own pages are unaffected. - **Repo hygiene: declared directly imported transitive dependencies in package.json** — `domhandler` (type-only, via `cheerio`), plus `axe-core` and `playwright-core` (dev, via the Playwright toolchain) are imported directly by source/e2e code but only existed as transitive deps; they are now declared explicitly so upstream reshuffles cannot break the imports. Also removed a legacy `(admin)/page.tsx` that resolved to `/` but was silently shadowed by the multi-page root redirect since #47, and a dead `dynamic` route-config export from the "use client" new-page form. - **Lighthouse CI workflow repaired** — the scripts-privatization commit left a seed step whose `run:` line was commented out but whose step shell remained, and a step with neither `run:` nor `uses:` invalidates the entire workflow file: every push to `main` failed at startup with zero jobs and no logs. The dead step is gone and CI now seeds through the app itself: the bootstrap admin is created via the first-run `/api/setup` endpoint and the default public page gets title/bio content, so Lighthouse audits a real profile page again instead of the setup redirect. - **Audit log action labels now resolve through i18n** — the audit table's action dictionary used flat keys containing dots (`account.created`, `role.assigned`, …), which next-intl rejects as invalid, spamming the browser console with `INVALID_KEY` errors and silently falling back to raw keys. The dictionary is now properly nested across all 11 locales (translations preserved), so `action.account.created` and friends resolve to their translated labels. - **Stalled link saves on automatic icons** — the favicon fetcher cleared its timeout as soon as response headers arrived, so a destination that sent headers promptly but stalled the body mid-stream hung `readBodyWithCap` forever: the save server action never returned and the link was never written. The timeout now stays armed until the body read finishes (same for the OG-image download), the per-fetch timeout is bumped 6s/7s → 10s, and the whole icon phase (every strategy, candidate, and the retry) is capped at 12s so a save can never block longer than that. - **SSO redirects behind Host-rewriting proxies (#124)** — `redirect_uri`, the post-login redirect, and error bounces were derived from request headers, so behind a proxy that rewrites Host (Pangolin, some nginx setups) sign-in bounced the browser to an internal address. `BASE_URL` now wins at all three sites (same rule as QR/vCard/OG). - **SSO session cookie dropped by the callback redirect (#124)** — the cookie was attached in a way a self-constructed redirect response discarded, so the flow finished signed out. Caught by a live round-trip against a mock IdP (`e2e/mock-oidc-idp.mjs`). ## [1.5.1] - 2026-10-27 ### Added - **Admin UI and README in Italian, Russian, Turkish, and Japanese** — four new languages, bringing the admin to 11 and the README to 11 translations: the Settings language dropdown, /login, /setup, the 2FA step, and the global error screen all switch (Russian uses the formal register standard for Russian software; Japanese drops ICU plural forms by design, as Chinese already does). Public pages stay English by design. Translated by AI agents and verified by a terminology-review pass; as with all machine-drafted locales, a native-speaker read before relying on a locale in production is worth it (docs/TRANSLATIONS.md policy). - **Catalog packaging for CasaOS, Runtipi, and Unraid (`packaging/`)** — LinkBreeze now ships first-party app-store packaging as canonical sources under `packaging/`: a CasaOS App Store app definition (`packaging/casaos/`, pinned image, icon, screenshot, en_US metadata), a Runtipi v4 app-store repository (`packaging/runtipi/`, to be published as `OmniRise/LinkBreeze-Runtipi-Store`), and an Unraid Community Applications template repository (`packaging/unraid/`, dockerMan-format XML plus repository profile and MIT license, to be published as `OmniRise/LinkBreeze-Unraid-Templates`). All three pull the official `docker.io/omnirise/linkbreeze` image with a pinned semver tag, mount the data volume, and need no environment configuration (the setup wizard handles first run). The packaging policy is documented in CONTRIBUTING.md: these three catalogs are maintained by the project; other platforms and hosting-provider deploy buttons stay community-PR territory. Per-release maintenance is one step: bump the pinned version in the three manifests during the release fold. - **Update status card in Settings (Data tab) (#123)** — A new card shows the installed version next to the latest release from GitHub and from the website, with a status line ("You're on the latest version." or "You can update to vX.Y.Z." with release-notes link and the pull command), a Recheck button that bypasses the 24h cache, last-checked time, and a "last known value" note when a source is unreachable. The GitHub source is a new unauthenticated latest-release check (24h-cached like the website poll); the dashboard update banner is unchanged. This was announced in the v1.5.0 release notes but its merge landed just after the `v1.5.0` tag was cut — it actually ships in this release. ### Changed - **Dependency updates (#126, #127)** — Merged two Dependabot minor-and-patch groups: #126 (`lucide-react` 1.59.0→1.61.0, `shadcn` 4.22.0→4.22.1, `sql.js` 1.15.0→1.15.1) and #127 (`next` 16.4.2→16.4.3, `eslint-config-next` 16.4.2→16.4.3, a bug-fix backport). Both merged with green CI and byte-exact lockfiles. Also bumped the `fast-uri` override ^3.1.7→^3.1.9 and added a `body-parser` ^2.4.0 override, resolving Dependabot alerts #66–70 (both packages are transitive through the shadcn CLI's express/MCP SDK chain, dev-tooling only). `node-forge` (#58) stays unchanged: no patched release exists, and `passkit-generator` uses it only for pass signing, never verification. - **Locale parity gate in CI** — CI now runs the locale hash gate (`npm run i18n:check`) and a generalized `scripts/locale-audit.mjs` across all ten locales, checking key parity, ICU placeholder parity, and untranslated-string suspects. The audit previously covered only French and Spanish and ran manually, so a broken `{placeholder}` in another language could reach main; its ICU handling was also corrected (it stripped plural placeholders, which would falsely flag Japanese and Chinese — both correctly drop ICU plurals — as mismatches). - **Language lists reordered: English first, then alphabetical** — one convention everywhere languages are listed: the admin language dropdown (picker order), the README translation rows in all eleven READMEs, `docs/TRANSLATIONS.md`, and the website locale lists (routing, sitemap, hreflang alternates) now read English · العربية · 中文 · Français · Deutsch · Italiano · 日本語 · Português (Brasil) · Русский · Español · Türkçe instead of historical add-order. - **Canonical repo URLs point at OmniRise/LinkBreeze** — All living documentation (README ×7, CONTRIBUTING, SECURITY, TROUBLESHOOTING) now references `github.com/OmniRise/LinkBreeze` instead of the pre-transfer `Manak-hash/LinkBreeze` URLs. The old links redirect, so nothing was broken; this makes the canonical location explicit and consistent with the Docker Hub namespace, the website, and the app-store listings. ### Fixed - **Unthrottled SSO endpoints (#124)** — the OIDC start action (10/min/IP — each call writes a state row and fetches discovery) and callback (30/min/IP) are now rate-limited like the local login; the state expiry check gained a 30s clock-drift leeway. - **Fresh API installs booted without an admin (#124)** — `/api/setup` created the first account with the default Member role, so API-installed instances had no working administrator (the setup wizard already passed the admin role). Both bootstrap paths now agree, with a regression test. - **New accounts could stay invisible in the admin list (#124)** — creating an account right after another save (e.g. a role) hit Next's client router cache and the list kept its stale payload even though the account existed; the create flow now reloads the page after the save settles. - **The update checker's GitHub fallback still queried the pre-transfer repo** — the update-check module called `api.github.com/repos/Manak-hash/LinkBreeze/releases/latest`; the canonical-URL pass in this release covered documentation only, and this one code path kept the old name. GitHub's rename redirect kept it working, but the checker now queries `OmniRise/LinkBreeze` directly. - **Local `npm run test` failed on the TOTP lifecycle test (#128)** — the enroll → activate → verify → disable lifecycle hashes 8 recovery codes with bcryptjs at cost 12 (single-threaded JavaScript: ~7.9s measured on modest dev hardware), well past vitest's 5-second default, so every local full-suite run died on `Test timed out in 5000ms` while CI's faster runner squeaked under and kept main green. The test now carries an explicit 20s budget with a comment; the hashing strength (cost 12) is untouched. Diagnosed as budget, not regression: the clean tree failed identically and the same run passed at a 30s timeout. - **Importing a Linktree page pulled Linktree's own affiliate links instead of the account's** — the migration wizard's `__NEXT_DATA__` extractor recursively searched the entire Next.js payload for any `{url, title}` array and swallowed `props.pageProps.statsigInitValues`, a statsig feature-flag blob whose sponsorship list is Linktree's global affiliate catalog (an identical ~26-item array on every profile: Hulu, HelloFresh, Headspace, and more). Importing any Linktree URL returned those promo links and buried or dropped the account's real links, and client-rendered mega-accounts returned only the promo list. The extractor now reads Linktree's real link containers first (`pageProps.links` / `socialLinks` and their `account.*` mirrors), the recursive walk skips config subtrees (`statsigInitValues`, `googleAdConfig`, blog teasers), and the inline-script tier no longer re-parses `__NEXT_DATA__` (which was re-introducing the blob). Client-rendered accounts now correctly report "No links found on this page" (use the File tab) instead of the affiliate list; Bento, Hopp, and Beacons extraction is unchanged, and regression tests cover the blob, the empty-SSR case, and the non-Linktree fallback path. - **Migration wizard's import button and social chip were hardcoded English** — the confirm button (Import N items) and the fallback label for an unrecognized social profile (Social) rendered in English regardless of locale; both now come from the locale dictionaries across all ten non-English languages, with correct per-language ICU plurals (Russian four-form, Arabic six-form, Japanese and Chinese plural-less by design). ### Security - **Update-check server actions skipped the demo guard (#128)** — the three update-check actions (the settings Recheck, the update-notification toggle, and the dashboard banner's refresh) checked the session but never called `demoGuard()`, unlike every other mutating action. On a public `DEMO_MODE` instance — where `DEMO_AUTO_LOGIN` hands every visitor a session — any visitor could flip update checks for all demo users and bypass the 24-hour cache on demand, driving uncached outbound fetches to the GitHub and website endpoints from the host server. All three now guard first and throw the standard read-only-demo message before touching the session or the network, covered by a new action test. ## [1.5.0] - 2026-10-24 ### Added - **@ in page slugs (requested by @stuff-by-rene)** — Page slugs can now optionally start with an @ (`/@churchname`), the handle style people know from other link tools. Opt-in per page: slugs without @ behave exactly as before, `/go/` redirect slugs stay letters-and-hyphens, and an @-slug can never shadow an app route (it is always a different string from every reserved name). Percent-encoded forms (`%40`) resolve to the same page. - **Language selector redesigned as a compact dropdown** — The row of seven pill buttons on /login, /setup, the 2FA step, and the Settings language card is replaced by one shared dropdown: a small pill showing only the current language that opens a panel listing every locale with its endonym, English name, and locale-code chip. The panel is keyboard-navigable (arrows/Enter/Escape), flips upward when the trigger sits low on the page, follows the trigger on scroll, and renders above cards and modals. No locale-file changes — new languages appear in it automatically. - **Custom domains for pages (#85, reported by @einlichtvogel)** — Bind your own domain names to pages, one instance serving many: each page can own a primary domain plus aliases (e.g. `jane.com` + `jane.io` + `www.jane.com`), and visitors land on the bare domain to see the page — no `/slug`. The new **Domains** admin section manages bindings with per-alias behavior (serve / 301 to the primary domain / 301 to the page slug) and per-page **scope**: *exclusive* (the domain IS the page — other slugs 404) or *hub* (page at the root, every slug still resolves). An instance-wide routing policy card controls the primary host (also via the `LINKBREEZE_PRIMARY_HOST` env var), a **strict admin host lock** (404s admin/login/setup on non-primary hosts — anyone can point a domain at your server; your login page never serves there), unknown-host fallback (path routing / 404 / redirect to primary), implicit www handling, and the canonical strategy (301 slug→domain by default, canonical-tag-only, or off). Bindings flip from *pending* to *active* on their first real request — DNS propagation becomes self-verifying — with a manual activate-now toggle and a DNS-record checker for pre-flight testing. SEO is first-class: canonical/og URLs point at the domain root, sitemaps list every active identity, and slug URLs 301 to their domain. Zero behavior change for instances that never bind a domain (migration `0028_custom_domains` adds one empty table + one nullable column); full reverse-proxy recipes (Caddy on-demand TLS, nginx, Traefik) in `docs/custom-domains.md`. All new UI ships in all seven locales. - **OG auto card redesigned as a "Poster Hero"** — The auto-generated social card no longer floats small text in an empty frame: big centered identity (170px avatar or theme-colored initial, 82px name, full-opacity bio) plus the page's top four link titles as themed pills with a "+N more" tail, so shares preview the actual content. The real LinkBreeze mark sits top-left and the two-tone wordmark top-right. Everything derives from the page theme (text/primary/secondary colors, all ten background types incl. photo backdrops with the theme overlay) via the new satori-safe `withAlpha` color helper and `selectOgLinks` picker — verified on dark, light, warm-gradient and aurora presets. - **Automatic scheduled backups (#83)** — A "Scheduled backups" card in Settings → Data turns on app-internal backups with no cron required: daily/weekly/monthly frequency plus a preferred time of day (runs at the first activity on or after it), keep-last-N retention (default 7), and the exact same JSON format as the manual export so restore is the proven path — stored backups restore through the identical validated transactional flow, with date/size listed and one-click restore/delete behind the same confirmation dialogs. Backups write atomically to `./data/backups` (env-overridable via `BACKUP_DIR`), a disk-space guard (`BACKUP_MIN_FREE_MB`, default 50) skips writing and reports instead of failing, and concurrency rules hold: never two backups at once, never a backup mid-restore. The trigger is opportunistic on page traffic and admin loads, off by default, and a dismissible dashboard notice (re-arming only on the next failure) reports failed runs. No migration — configuration lives in the existing settings table. - **Custom /go/ redirect slugs (#96)** — Links can now carry an optional custom redirect slug alongside their numeric ID, so a shared, printed, or embedded redirect URL survives delete-and-recreate: `/go/my-consulting` instead of `/go/42`. Slugs are lowercase `[a-z0-9-]`, unique, up to 64 characters, with a reserved-word blocklist (`login`, `setup`, `dashboard`, `links`, `settings`, `api`, `sitemap.xml`, `robots.txt`, and all pure numbers) so they can never shadow app routes. The link dialog gains a slug field with live availability checking, deleting a link releases its slug for reuse, and a deactivated link that has a slug redirects visitors to its public page instead of a bare 404. Numeric `/go/:id` URLs keep working unchanged, and backup restore sanitizes invalid or duplicate slugs rather than rejecting the file. - **Official OmniRise Docker Hub image (`omnirise/linkbreeze`)** — LinkBreeze is moving under the OmniRise brand with an official Docker Hub presence as the default public pull path. The release workflow now publishes per-platform images to both Docker Hub and GHCR and assembles both manifest lists from the same content-addressed digests (one build, two registries, byte-identical images), and the dry-run mode still builds without pushing anything. First published tags: `latest` and `v1.4.1`, multi-arch `linux/amd64` + `linux/arm64`, anonymous pulls verified working. - **One-shot update script (`update.sh`)** — Self-hosted instances on any install method can now update with `curl -fsSL https://linkbreeze.omnirise.dev/update.sh | sh`. The script finds the existing container (by name or by image), backs up the data volume to `~/.linkbreeze/backups//`, pulls the current image, and recreates the container with the same port mapping, volumes, environment variables, and restart policy — then prints the new version, the backup path, and a ready-to-paste rollback command. Tested end-to-end against both a standard named-volume install and a bind-mount compose install: data, env vars, and ports all survive; the script warns and continues if the backup itself fails, never touching the data volume until the new image is pulled. - **Update banner shows the exact update command** — The "new version available" banner previously linked to release notes only, leaving operators to figure out the update steps themselves. It now displays the copy-paste command (`docker pull omnirise/linkbreeze:latest && docker restart linkbreeze`) plus the one-shot `update.sh` URL right in the banner, next to the release-notes link. - **Social preview (OG) images — theme-following or your own upload** — Every page now renders a 1200×630 share card at `//opengraph-image`. Auto mode draws the card from the page's live theme through a pure resolver (`src/lib/og-theme.ts`): every background type gets a faithful static rendering (multi-stop gradients at the theme's angle, radial at the same focal point, mesh as layered radial stops, image/gif backdrops with the theme's overlay, aurora as base color + accent glows, video as the loading-state gradient), plus the page avatar and title in the theme's text/link-card styling — change the theme and the share card changes with it, no re-upload. Custom mode serves an uploaded image directly as `og:image`, with the shared fit/focus/zoom picker (adjustment-only, non-destructive) and a live in-card preview; crawlers' aggressive caching is called out in the UI with guidance to re-validate. The card is managed from its own autosaved card in Settings (mode toggle saves instantly, uploads pair with custom mode in one action). Migration `0027_og_image` adds six nullable `pages` columns (`og_image_mode/url/fit/pos_x/pos_y/zoom`) — existing installs boot unchanged and default to auto. ### Changed - **Dependency updates (#117, #118, #119, #120, #121)** — CI: `actions/download-artifact` 7 → 8. npm minor-and-patch group (16 packages): `react`/`react-dom` 19.3.1, `lucide-react` 1.59.0 (#119: 1.58.0, #120: 1.59.0 — icon render-contract test green, full suite passing), `shadcn` 4.22.0 (#120, dev CLI), `hono` 4.13.13 (#121), `next-intl` 4.14.9, `passkit-generator` 3.7.0, `drizzle-orm` 0.45.4, `zod` 4.6.7, `@base-ui/react` 1.9.0, Playwright 1.64.1 + axe 4.14, `drizzle-kit` 0.31.11, `eslint-config-next` 16.4.2, `sql.js` 1.15.0, `vitest` 5.0.5 — no breaking changes. - **Install script and update checks served from linkbreeze.omnirise.dev instead of raw.githubusercontent** — The one-line installer lives at `linkbreeze.omnirise.dev/install.sh` (marketing site, proper `text/x-shellscript` content type), and the update checker polls `linkbreeze.omnirise.dev/latest-version.json`. Both previously depended on `raw.githubusercontent.com`, which is unreachable during any GitHub account visibility issue — instances v1.2.0–v1.4.0 polled a 404 and silently never saw update banners; this change restores the notification chain for every install from this release forward (older installs pick it up when they next update). The website-served `latest-version.json` is the source of truth for update checks, and its `url` field points at the canonical GitHub release, so the update banner link stays live even if the mirror lags — the GitLab mirror remains a passive pull copy. - **All documentation and examples reference the official registries** — Every README translation (7 languages), TROUBLESHOOTING, docker-compose.yml, the demo compose file, all seven example compose files, and kubernetes.yaml now use `docker.io/omnirise/linkbreeze:latest`. The README gains a "Repositories & Registry" section (GitHub, GitLab mirror, Docker Hub) with the Docker badge linking to the Hub page, and the Quick Start adds the direct `docker run` alternative alongside the install script. README translation hashes re-synced (`docs:check` green). - **Source mirrored to GitLab (`gitlab.com/omnirise00/linkbreeze`)** — A public pull mirror (8h interval) of the canonical GitHub repository, giving LinkBreeze a second independent forge home. README's new registry section lists GitHub as canonical with the mirror second; all user-facing links (website CTAs, update banner, structured data, docs) point at GitHub. - **Settings → Appearance reorganized around visuals** — The social preview (OG) card moves from General to Appearance (it is page branding, not SEO text). The tab now leads with a theme showcase card that shows ONLY the active theme — a token-true mini preview (same renderer as the /theme preset gallery), name, active badge, and color swatches — with a "Change theme" button that expands a grid of visual previews for the rest; picking one saves immediately and collapses the grid. The favicon uploader gets its own prominent branding card with a large live preview, and Custom CSS moves into its own container at the bottom of the tab (with its slug set on legacy single-profile installs, where the flush previously omitted it). All new UI strings ship in all seven locales. - **Admin sidebar logo links to the website** — The LinkBreeze logo + wordmark at the top of the admin sidebar (and the mobile header) now open `linkbreeze.omnirise.dev` in a new tab, with a hover tooltip ("Visit linkbreeze.omnirise.dev") so the affordance is discoverable. ### Fixed - **Test suite robustness: backup-store date bomb and timezone-fragile scheduling test** — `backup-store` "lists only backup artifacts" hardcoded a newest-fixture stamp (2026-10-19T03:00Z): every run after that instant — local and CI — sorted the freshly written timestamp-stamped backup ahead of the fixture and failed. The listing test now clears live-stamped artifacts before asserting, so the fixtures control ordering regardless of the wall clock. The `backup-schedule` "monthly uses 30-day steps" test built its before/after instants by millisecond arithmetic off the September slot; in timezones with a DST transition inside the 30-day window (Africa/Casablanca +01→+00 on Oct 4) that landed an hour off the local 03:00 slot and flipped the result. It now constructs the before/after instants from local calendar dates, and passes in UTC and +01 alike. - **Page changes update the social preview immediately** — The Appearance tab's OG card preview cached the rendered share card: its cache-buster only tracked save counters, so changing the theme, display name, bio, or avatar kept showing the stale card until a manual reload. The preview URL now carries a server-computed hash of every page input the card renders (theme, title, bio, avatar, og fields) — any edit anywhere re-fetches the freshly rendered card through the `router.refresh()` that every autosave already triggers. - **Settings tabs survive a refresh** — The active tab lived in component state only, so refreshing (or reopening) `/settings` always landed back on General. The tab id now rides in the URL hash (`/settings#security`), survives refreshes and shared links, and composes with the `?page=` page switcher. - **Avatar, banner, and favicon uploads now persist** — Uploading through the profile avatar/banner buttons or the Settings favicon uploader only updated the on-screen preview: the new URL lived in a controlled input (or hidden field) whose value the autosave flush never read, so the upload silently reverted on the next page load unless an unrelated field was edited afterwards. Every uploader now flushes the same debounced autosave path one frame after the value commits — uploads behave exactly like typing. The legacy (no-pageId) custom-CSS flush additionally set the required `slug` field, which it previously omitted. - **Setup wizard and settings slug validation actually validates** — The slug inputs' HTML `pattern` attributes (`[a-zA-Z0-9_-]+`) are invalid under the `v` flag browsers now compile patterns with, so Chrome silently dropped the pattern and logged a `SyntaxError` in the console on every setup/profile render: any slug passed client-side until the server rejected it. The dash is now escaped (`[a-zA-Z0-9_\-]+` — the only v-mode-valid form, verified against the regex engine), the wizard's full three-step flow was re-driven end-to-end in a real browser on a fresh install (invalid slug rejected client-side, zero console errors), and the same fix was audited across every `pattern=` attribute in the app. ### Security - **Argon2id password hashing (#76)** — Admin passwords are now hashed with Argon2id (OWASP parameters: m=19456 KiB, t=2, p=1) instead of bcrypt (12 rounds). Existing bcrypt hashes keep verifying and are transparently re-hashed with Argon2id on the next successful login or password change. This also removes bcrypt's silent 72-byte input truncation, which dropped meaningful content from multi-byte passwords. New dependency: `@node-rs/argon2`, shipped in the multi-arch image for both amd64 and arm64. - **Custom-domains hardening (breach-test findings)** — Host routing now trusts only the raw `Host` header; `X-Forwarded-Host` is honored solely behind the new `LINKBREEZE_TRUST_X_FORWARDED_HOST=true` env flag (for proxies that overwrite it, like nginx/Caddy). Previously a client-supplied `X-Forwarded-Host` — which header-forwarding proxies such as cloudflared pass through verbatim — could bypass the strict admin host lock (login/dashboard/setup reachable on any host), defeat the unknown-host 404 fallback, and flip a pending domain binding to active without its DNS ever resolving. The pending→active flip now additionally requires the connecting `Host` header to match the binding, so a spoofed forwarded host can never mint an "active" domain. With the unknown-host fallback set to *404*, the canonical 301 no longer fires on unknown hosts, so bound slugs stay hidden (the fallback wins). On exclusive domains, `/api/vcard/` and the Google/Apple wallet pass routes now serve only the bound page's slug — an exclusive domain IS one page, and no longer hands out other pages' artifacts. `/api/themes/export` and `/api/totp-qr` join the admin host-lock list. Behavior is unchanged for instances that never bind a domain and for the default fallback setting. - **Server-action and public-route hardening (full-app breach test)** — A whole-repo audit closed the remaining gaps the custom-domains pass didn't cover: `toggleUpdateCheck`/`refreshUpdateCheck` and `dismissBackupNotice` were callable with no session at all (anyone could disable update checking or hide the "backups are failing" dashboard signal) and are now session-gated; `checkDomainDnsAction` is now demo-guarded, so the public demo no longer doubles as a DNS-resolution oracle; the QR, vCard and wallet routes build their encoded/canonical URLs from the raw `Host` header (same rule as the domain-routing proxy, same `LINKBREEZE_TRUST_X_FORWARDED_HOST` opt-in) instead of preferring a spoofable `X-Forwarded-Host` that could steer QR codes to an attacker's host; and CSV exports (analytics + subscribers) now neutralize formula injection — analytics referrers are attacker-controlled via the Referer header, and a `=HYPERLINK(...)` cell would execute when the admin opens the export in a spreadsheet. - **Import-path, setup and 2FA hardening (whole-app audit)** — The migration wizard's confirm step re-runs the URL scheme allowlist over every imported link and social profile (and the structured `__NEXT_DATA__` extractor filters schemes at extraction): a crafted import file or hostile source page could previously store a `javascript:` URL that rendered as a live link on the public page. Theme-file import and backup restore now run every theme row through the same field schema as the save path (colors, media URLs with https/http/site-relative-only, enums, position formats) instead of accepting raw strings — theme fields are interpolated into the public page's CSS, so a crafted community theme could inject arbitrary CSS. Setup mints the first admin through an atomic insert-with-count-recheck (two concurrent POSTs on a fresh instance could previously create two admin rows), onboarding enforces the reserved-slug blocklist (slug `login` used to make the public page unreachable), the login/TOTP `?from=` parameter only follows site-relative paths, manual backup restore takes the backup lock (a scheduled backup could snapshot a half-restored database), favicon fetches enforce real response size caps, and `/api/themes/import` caps its parse at 2MB. `disableTotp` no longer fails open: when the TOTP secret is undecryptable (e.g. after a SECRET_KEY rotation) disabling now requires a recovery code — hashed independently of the TOTP secret — instead of accepting any well-formed string. TOTP confirm is rate-limited like the login step, the pending setup cookie is cleared on success and marked Secure, and stale `pv` sessions no longer trigger a cookie write during page render (which threw in Next 15/16 and could lock every admin page including the login screen after a password change on another device). ## [1.4.1] - 2026-09-23 ### Added - **Live preview follows every edit; autosave on the forms it watches** — The admin preview pane previously lagged one save behind: its iframe cached the page, so you had to save, then hit refresh, to see the last-but-one state. The pane now cache-busts every reload (`?v=`, including the manual refresh button), and a single shared `useSavedAction` hook drives every admin save (profile, links and section dialogs, delete confirmations, drag-to-reorder, settings tabs, theme fork/customize, migration wizard, new-page flow) so a successful action always refreshes the router data AND reloads the preview in one uniform path. Forms the preview watches autosave as you type — debounced 600ms, deduped against the last-saved baseline so re-renders don't re-POST identical state: the theme editor (custom themes only; presets keep the explicit fork/duplicate dialog), the profile identity fields and social links (adding a chip, typing a URL, or removing one all schedules the same save), and the Settings General, Integration, and Appearance tabs (analytics, consent, footer, SEO text, custom CSS; the email-capture and share switches still save instantly on toggle, theme chips on click). A brief "Saving… / Saved" note appears where the old button sat while a flush is in flight, and the autosaved forms dropped their now-dead Save buttons and footer strips entirely — the profile/slug field stays manual on purpose (autosaving a half-typed slug would break the public URL), and the cards that keep an explicit button are exactly the ones autosave must never touch: the page settings dialog, link/section dialogs, migration confirm, security/2FA, and anything destructive. Security forms are exempt by design: typing in the password/2FA fields fires zero requests (verified in a live browser with the request log and the bcrypt hash asserted unchanged). - **Non-destructive image positioning for avatar, banner, and background** — Every image surface gains fit/focus/zoom adjustments computed at render time, never baked into the uploaded file: repositioning is reversible and re-uploading is never needed to fix a crop. The three surfaces share one picker component that auto-opens right after an upload, shows the image in a frame with a live `X · Y · zoom` readout, supports drag-to-reposition, wheel-to-zoom over the image, fit Cover/Contain chips, and a reset — and persists via the same debounced autosave path as the rest of the profile form. Internals: migration `0025` adds twelve nullable `pages` columns (`avatar_/banner_/background_` × `fit/pos_x/pos_y/zoom`; the background trio is a per-page override of the theme values), a pure resolver (`src/lib/image-adjustments.ts`) turns NULLs into pixel-identical output so every existing page renders exactly as before, zoom scales `background-size` (the contain branch emulates it via a percentage height/width pair), video backgrounds take fit+position only (no zoom on a moving asset), and the picker's debounced saves survive re-render storms because `useSavedAction` returns a stable identity now. The focal-point/position metadata round-trips through backup/restore like the rest of the page row. - **Live preview keeps its scroll position across reloads** — Every autosave reloads the preview iframe, and a fresh document always starts at the top, so editing a link near the bottom of a long page while scrolled down meant the preview jumped back up on every keystroke's flush. The pane now records the iframe's scroll offset continuously (a passive scroll listener on the frame's window, stored in a ref that survives the remount-the-iframe reload strategy) and re-applies it after each reload finishes — clamped to the new document's scrollable height so a change that shortens the page lands at the bottom, not in a void. The manual refresh button restores position too; only switching pages or closing the pane resets it. - **Share & wallet exports: vCard, Google Wallet, Apple Wallet** — Every public page can now show a share block, **opt-in per page** via a new "Share block" toggle in Settings → Integration (off by default). "Save contact" is a universal vCard 4.0 download (no configuration): name, handle, page URL, bio and org fields, RFC 6350-compliant (CRLF line endings, 75-octet folding, full value escaping) and hardened against adversarial input — card-boundary injection via embedded newlines, control characters, and traversal-style filenames are all contained, covered by a dedicated security test suite. Beyond the vCard, operators can enable "Add to Google Wallet" and "Add to Apple Wallet" buttons by bringing their own credentials as environment variables (see the new `.env.example` section and the Configuration table in the README): Google needs a free issuer ID + service-account JSON, Apple needs the five `APPLE_WALLET_*` variables from a paid Developer account. Both wallets render stateless static passes — a business card carrying the page identity, a QR that always opens the live page, and a snapshot of the current links on the back ("Saved on" labeled; passes are frozen at install by design, no push infrastructure). Google passes are issued just-in-time as RS256-signed JWTs via `node:crypto` (no new JWT dependency); Apple passes are built with `passkit-generator` and served with the exact `application/vnd.apple.pkpass` MIME type iOS requires. The routes are read-only (the demo instance shows the feature), rate-limited like the QR route, and 503 when credentials are absent — the buttons themselves only render when the matching credentials exist, verified both ways in live testing. A new "Wallet & contact exports" card in the same Settings tab shows per-wallet credential status (never secret values) and warns before the Apple signing certificate expires — the silent-expiry failure mode being the classic "passes suddenly stopped working" support trap. Testing: 60 tests acros... [truncated] - **Public page footer rhythm rework** — The bottom of every public page (email capture, share block, footer/Privacy) is now wrapped in one flex-column container (`.lb-footer-zone`) whose `gap` owns all inter-element spacing — per-element `mt-*` margins that collapsed unpredictably and changed shape as features toggled on/off are gone, and every spacing value derives from `calc(var(--lb-spacing) * N)` so each theme's density setting scales the whole rhythm. Optical symmetry pass: "Save contact" sits with matching space above and below (the divider pairs it with Privacy at an even 42px beat), Privacy's space below now matches its space above, and the page container uses asymmetric top/bottom padding (`pt-12 pb-6`) so the footer's padding no longer doubles into a dead zone under Privacy. Measured from rendered pixels (headless-Chromium screenshots + row-brightness scan), not just class names. - **Admin UI in Brazilian Portuguese (#111, contributed by @WillxRv)** — Complete `pt-BR` dictionary ships with full key parity alongside en/fr/es/de/zh/ar, and Português (Brasil) joins the Settings language picker. `pt-BR` was a pre-registered T3 slot in the locale registry: the BCP-47 plumbing (the `localeTag` map, `LOCALE_NAMES` endonym, `LOCALE_HTML_LANG`, and both chart date-format switches) was already wired for exactly this entry, so the diff activates the reserved tier rather than growing the registry. The dependency-free global error boundary carries inline pt-BR copy alongside the other six locales, so the crash screen renders in-language even when the i18n layer itself caused the crash. The e2e i18n spec gains a pt-BR login case, `README.pt-BR.md` translates the full README with language-nav links added across all seven existing translations, and `docs/TRANSLATIONS.md` tracks it. ICU plural forms are used where counts appear; Latin digits per the i18n policy. ### Changed - **Dependency updates** — Merged Dependabot's npm minor-and-patch group (#113): `lucide-react` 1.45→1.47, `next` 16.3.4→16.3.5, `next-intl` 4.14.3→4.14.5, `tailwind-merge` 3.6→3.7, `zod` 4.6.2→4.6.5, `@types/node` 26.5→26.6, `eslint-config-next` 16.3.5, `vitest` 5.0.1, plus the joi override below which clears both Dependabot security alerts. ### Fixed - **Cleared text fields no longer revert on reload** — Deleting the page title, SEO title/description, footer text, privacy policy, custom CSS, analytics script, bio, or a profile URL and letting autosave fire looked like it saved — but a refresh brought the old text back. `updatePageAction` read every text field as `value || undefined`, so an empty submission was indistinguishable from an absent one and hit the "leave unchanged" path; the old value stayed in the database and the revalidated page re-rendered it. The action now uses true tri-state semantics (`formData.has()` = write the value, even empty; absent = leave unchanged), with nullable columns canonicalizing `""` to NULL. Verified in a live browser across all affected fields: fill → autosave → delete → autosave → reload still shows the cleared state. - **Dependabot alerts #26/#27: transitive joi prototype pollution** — `passkit-generator` pins `joi` 17.13.4, vulnerable to two prototype-pollution advisories (`object().rename()` with a template target can set the validated object's prototype; a `__proto__` language key in custom messages pollutes `Object.prototype`). joi only validates pass.json shapes inside the library — never attacker-controlled input before validation — so exposure was low, but an npm `overrides` entry now forces `joi@^17.13.6` (resolves 17.13.8, API-compatible), and `npm audit` reports zero vulnerabilities. - **Social platform picker offered the same X logo twice** — The platform grid listed both "X (Twitter)" and "X" as separate choices, but both rendered the identical X glyph, so the picker showed what looked like a duplicate button (and a saved "twitter" link plus a saved "x" link could both appear in the list). `twitter` is now a recognized alias rather than a listed platform: the picker offers a single X entry, URL detection maps twitter.com and x.com to it, old links saved with the legacy `twitter` platform id keep rendering and editing correctly, and the labels/`getSocialIconSvg` switch treat the two as one. - **Code scanning: media URL scheme allowlist (alerts #20/#21) and test-file escape pattern (alert #19)** — The avatar, banner, favicon, and background image URLs ultimately land in ``, `