Changelog
What changed in LinkDen. Each release has a plain-language summary for site owners and a technical section for developers.
TL;DR
- Latest release: 0.5.0 (2026-08-21)
- Format: β¨ What's new for site owners + π§ Technical for developers and self-hosters.
- Source of truth:
CHANGELOG.mdin the repository root.
Each release below has two sections:
- β¨ What's new β plain-language summary for site owners.
- π§ Technical β internal changes, refactors, security details for developers and self-hosters.
The canonical version number lives in version.json at the repository
root. This page mirrors
CHANGELOG.md β
that file is the source of truth.
Unreleased
β¨ What's new
- Two-factor sign-in works. Turn on an authenticator app under
Settings β Account and save your backup codes. After your password,
the login page now asks for the 6-digit code (or a backup code), with
an optional "trust this device for 30 days". Before, turning 2FA on
could lock you out of your own admin. Lost your phone and your codes?
bun reset:password -- --email you@example.com --disable-2fa. - CAPTCHA actually shows up. Cloudflare Turnstile, Google reCAPTCHA (v2 checkbox) or hCaptcha now renders on the public contact form and is checked on the server. Site and secret keys are both required once a provider is picked.
- A rebuilt admin console. Five places instead of eight: Links, Design, Insights, Inbox and Settings, with a sidebar, a live phone preview on every editing page, and an honest status pill (Unsaved / n unpublished / Live). Every page has its own browser-tab title, and old admin links redirect to their new home.
- New blocks and a desktop layout. Image, text and divider blocks; link cards in button, thumbnail or featured-image style; headers that lay out the blocks below them as a list, grid or carousel. On wide screens your profile sits in a sticky column beside your blocks. A share button (with QR code) sits on the page, and the page and admin can be installed as apps.
- Editing a live block keeps it live. Renaming, dragging or hiding a published block no longer pulls it off your page until you click Publish. Publish now only promotes brand-new blocks ("Publish 3 new").
- Destructive actions ask first. Deleting a block, importing a backup and applying a wallet template preset all confirm before anything changes, and Undo puts a deleted block back in its old spot.
- Apple Wallet passes are now downloaded by you, not your visitors.
The public "Add to Wallet" button and
/api/wallet-passare gone. Download the signed pass from Settings β Wallet ("Downloads enabled") and share it however you like. - Wallet signing keys are easier to get right. Upload your
.pemor.cerfiles (or paste PEM) and they are checked before saving. "Ready" now means the keys can really sign a pass, and each saved key can be cleared to fall back to the environment value. - The footer "Save vCard" button works without a vCard block. It downloads the contact card from Design β Branding.
- Contact forms that disappeared after an upgrade are back. Old
form/contact_formblocks are converted to Connect blocks. - Your visitors are never told a saved message failed. If the email notification fails (or email isn't set up), the message is kept in your Inbox and the failure is logged. Contact-form delivery (Inbox, email or both) can now be chosen under Settings β Email.
- Cookie consent visitors can change. A footer "Cookie settings" link reopens the banner, a choice expires after 12 months, Accept and Reject look equally prominent, and embedded media (YouTube, Spotify, SoundCloud, custom) waits for a click while the banner is on. A first visit is still counted once the visitor accepts analytics. If you turn the banner off, analytics run for every visitor without asking.
- Less data about visitors. Browser user-agent strings are no longer stored, referrers are kept as a bare hostname, and "Where did we meet?" is only asked when you switch it on for a form.
- Inbox upgrades. Older messages load page by page ("Showing n of total"), a Select mode marks read or deletes in bulk, opening a message marks it read once (Mark unread sticks), timestamps follow the timezone in Settings β Account, and reply links can't be hijacked by a crafted email address.
- Insights in your timezone. Daily views and clicks follow your timezone setting, every day of the period shows (zeros included), dates no longer show a day early west of UTC, "All time" no longer claims +100%, the Clicks line is dashed, and the chart has a screen-reader table.
- Paste a social profile link. Social rows accept a full profile URL
or an
@handleand store a clean link (no morehttps://x.com/https://x.com/ada). - Custom theme colours work in dark mode too. Leaving a colour empty now means "use the preset" in both light and dark, and picking a preset clears your overrides. Colours you saved before stay pinned until you clear them.
- Sign-in is harder to abuse. Resetting your password signs out every device, changing your email needs a link from the old address and then the new one, magic links can't be turned on without email or while 2FA is on, and login sends you back to the page you came from.
bun reset:passwordno longer locks you out, works against production, and never takes the password on the command line. Production deploys refuse unsafe auth settings.- Claim your install safely. Set the optional
SETUP_TOKENsecret and only someone who knows it can create the admin account on a fresh public deploy. - Image fixes. Discarding an image change no longer breaks the saved image, crops upload on Safari/iOS, pinch-zoom no longer tilts a crop, and Enter on the crop dialog's buttons does what the button says.
- Accessibility and polish. Form fields, checkboxes and off switches have visible outlines; keyboard focus shows in the account menu and segmented toggles; light-mode success/warning badges pass AA; the Grain Dark banner shows its grain; Corporate Classic dark mode uses a readable lighter blue; shader banners freeze under reduced motion and have a pause button; the sidebar remembers if it was collapsed; browser Back asks before discarding unsaved edits; reverted edits no longer show "Unsaved"; "Show preview" keeps your block edits; the Admin panel branding switch works; a "system" colour mode renders in the visitor's OS theme without a flash on Chromium.
π§ Technical
Added
- Better Auth
twoFactorplugin (issuer "LinkDen") with thetwo_factortable (encrypted secret + backup codes, FK βuserON DELETE CASCADE). Migration0008_two_factoradds it and drops the unuseduser.two_factor_secret/two_factor_backup_codescolumns.two_factor_enabledturns on only after the first TOTP code verifies. - Migration
0009_auth_and_block_data_fixes(data only): backfillsuser.email_verified = 1and convertsform/contact_formblocks toconnect. Migration0010_drop_social_icons_blocksremoves retiredsocial_iconsblock rows. createAuthOptions(deps)inpackages/auth/src/auth-options.ts: the env-free Better Auth factory;index.tsonly wires env, D1, Resend and secret validation, and tests build instances from the same factory.- Optional
SETUP_TOKENsecret (apps/server/src/lib/setup-token.ts): when bound, the first signup must send a matchingx-setup-tokenheader (constant-time compare) or gets 403Invalid setup token. Bound by Alchemy only when set; wired intodeploy.yml,turbo.jsonand.env.example. RL_SIGNINrate limit (30/60s, namespace 1006 prod / 2006 non-prod) with constant keysglobal:sign-in/global:two-factor/global:password-reset, so the single admin can't be brute-forced by rotating addresses.components/public/captcha-widget.tsx(Turnstile, reCAPTCHA v2, hCaptcha) sendingcaptchaTokenwith actioncontact;getPageexposescaptchaProvider+captchaSiteKeyonly.forms.count({ isRead?, blockId? });forms.listtakeslimit/offset.submitContactgains awebsitehoneypot and an optionalwhereMet;trackView/trackClicktake an optionalreferrer.version.checkUpdatetakes{ force?: boolean }.resolvePublicVcard()(packages/api/src/routers/vcard.ts): single source forGET /api/vcard(?block=<id>, else the first live vCard block, elsevcard_data).- Settings registry: opaque keys can declare
validate(vcard_data, consent_categories, wallet field/location JSON, relevant date, signing material); newsite_languagekey (Design β SEO) drives<html lang>. @linkden/validators/consent,MAX_BLOCKS = 500(@linkden/validators/blocks),@linkden/db/settings-read(readSettings,AnySqliteDb).- Backup export adds top-level
notesand animageslist of referenced keys (images themselves are still not in backups). - Web:
lib/admin-routes.ts,lib/theme-colors.ts,lib/custom-css.ts,lib/contact-form.ts,lib/block-icons.ts;components/admin/analytics/daily-series.ts+admin-time-zone.ts;components/admin/social/social-url.ts;components/admin/connections/mailto.ts;SecretInput;mutationErrorMessage;--input-bordertoken (border-input-border);unsavedEditSnapshot();prefersReducedMotion()/useReducedMotion(). .github/workflows/release.yml: aversion.jsonchange onmaintagsvX.Y.Zand publishes a GitHub release, soversion.checkUpdatehas something to compare against.- Deploy: preflight step naming missing secrets/vars, a D1 Time Travel restore-point bookmark in every job summary, and a web smoke probe.
- Tests: Worker-entry route tests (
apps/server/src/__tests__/routes.test.ts), router integration tests inpackages/api/src/routers/__tests__/, and per-glob coverage floors for routers, the tRPC middleware, auth-options/dev-login,apps/webproxy.ts+lib/**,packages/infra/deploy-config.tsandscripts/lib/reset-sql.ts.PW_API_URLoverrides the API origin for the e2egetPagecheck.
Changed
- Rate limits apply per tRPC call. A Hono
/trpc/*middleware parses the batch like tRPC does and spends one token persubmitContact(RL_AUTH) /trackView/trackClick(RL_PUBLIC) call; batches are capped at 20 (maxBatchSize) and oversize or undecodable batches get 400. Limiter keys are the client IP with IPv6 collapsed to its /64./api/auth/two-factor/*is limited (RL_AUTH + RL_SIGNIN) and the magic-link send path gets RL_STRICT. The server callsRateLimit.limit()directly (@hono-rate-limiter/cloudflareis no longer imported). - Better Auth's built-in limiter is explicitly enabled
(
rateLimit: { enabled: true }, in-memory per isolate; it was always off on Workers becauseNODE_ENVis never "production"): 3/10s for sign-in, change-email, change-password and/two-factor/*, 5/60s for magic link. - Request logging:
hono/loggerreplaced by a path-only line (METHOD path status ms, reset-password token redacted).logError(apps/server/src/lib/log.ts) strips Drizzle bound params. Workers invocation logs are off./api/healthlogs the D1 error when degraded. - Retention cron also prunes expired
verificationrows (reset, magic-link, 2FA challenge, trusted device) in the same batch, logs each failed step (pathscheduled, stepprune|orphan-sweep) and rejects with anAggregateError. Orphan deletes are batched per listed page and counted only when R2 confirms them. - Uploads:
replacesis honoured only for a same-purpose key that no saved setting or block references.ImageUploadFieldsends it only for an upload made during the current unsaved edit; saved-then-replaced objects go to the daily orphan sweep (24h grace). GET /api/images/*404s key shapes the app never writes without an R2 read, sends anETag, and edge-caches viacaches.default(cache key without the query string).requestMetareturns only{ ip, country }; analyticsuser_agentis written NULL (truncateUserAgentremoved) and the Referer header is no longer read. The clientreferreris stored as its lowercased hostname and dropped for the site's own host.verifyCaptcha: empty secret = misconfigured; hostname must equal theCORS_ORIGINhostname whenever it is set (fails closed if unparseable); Turnstile always needs actioncontact; reCAPTCHA v3 needs actioncontactand score β₯ 0.5.- tRPC:
isDevpinned false; anerrorFormatterstripsdata.stackand replaces INTERNAL_SERVER_ERROR messages with "Internal server error". - Magic link: single admin only, refused while 2FA is on,
disableSignUp, 10-minute links, enabled-gate in a Better Auth before-hook (403 before a token is stored); the server gate covers both/api/auth/sign-in/magic-linkand/api/auth/magic-link/*and needsmagic_link_enabledto be exactly"true". - Change email is two-step and returns 400 "Email delivery is not
configuredβ¦" without a Resend key. Password reset sets
revokeSessionsOnPasswordReset. The admin is created withemailVerified = true. BETTER_AUTH_SECRETfails closed: empty, or under 32 characters / a placeholder on any non-loopbackBETTER_AUTH_URL, means auth is never built;/api/auth/*,/trpc/*,/api/uploadand/api/admin/*answer 503 and/api/healthreportsauth: "misconfigured".DEV_LOGINneedsDEV_LOGIN="true"plus a plain-http loopbackBETTER_AUTH_URL, and the endpoint 404s for a non-loopback Host.alchemy.run.tsvalidates the deploy env before touching state (deployEnvErrors);LINKDEN_DB_NAME/LINKDEN_IMAGES_BUCKETapply to the prod stage only; prod D1/R2 usedelete: falseand a prod destroy needsLINKDEN_CONFIRM_DESTROY_PROD=yes; the Workers compatibility date is pinned (2026-04-24);apps/*/.envload only forbun dev.deploy.ymldeploys onlypushruns from this repo'smainand never cancels a running deploy.backup-db.ymlhonoursLINKDEN_DB_NAMEand refuses to upload a dump with nouserrow. CI runs Vitest once (test:coverage).bun reset:passworduses Better Auth's scrypt hash, reads the password from a prompt / stdin /LINKDEN_RESET_PASSWORD, signs every session out, fails when no account matches, and supports--remote,--dband--disable-2fa.bun reset:factoryis one atomic batch over every data table and asks you to type the DB name for--remote.bun db:resetwipes both local dev stores.drizzle.config.ts:db:generateneeds no credentials; thedb:pushscript is gone (push bypasses the migration ledger and the 0007 trigger).backup.importreplace mode keeps secret settings, upserts blocks in place (deleting ids missing from the backup, so click history of restored blocks survives) and never wipes the contact table (only messages absent from the backup are deleted; the newest 500 backup messages are upserted). Import accepts what export emits (ISO dates; epoch ms still accepted), maps retired block types, skips secret keys and counts them; the response addsskipped.contactSubmissions. Caps: 500 blocks, 500 settings, 5000 contacts on the wire / 500 written.- Wallet:
updateSigningKeysnormalizes base64-wrapped PEM to raw PEM, keeps base64 DER certs and rejects unloadable material (EC keys, PKCS#12, garbage) with BAD_REQUEST;getSigningStatusbooleans mean "configured and loadable"; the.pkpassroute caps field sections withPASS_FIELD_LIMITS; a wrongWALLET_SIGNER_KEY_PASSPHRASEfails with "Unable to load wallet signer key (wrong passphrase?)". Empty pass-field keys are rejected with the section and field number. - vCard output is RFC 6350 escaped (no CR/LF injection), always has an
Nline, puts department inORGand reduces URLTYPEto a token. - Blocks: the server assigns positions (append at
MAX(position)+1; Undo re-inserts at its old slot in one batch); lists order by(position, createdAt, id).stripHtmlstrips only real tags and no longer decodes entities (<jane@corp.com>andx < 5stay verbatim). danger.resetEverythingderives its table list from every table in@linkden/db/schema, so new tables (liketwo_factor) are wiped too.getPageis one D1 batch reading onlyPUBLIC_PAGE_SETTING_KEYS, honours privacy/terms URL vs text mode and falls the consent banner's privacy link back tobranding_pp_url. Insights day buckets use the admin timezone's current UTC offset. Timezone validation usesIntl.DateTimeFormat(UTC, Asia/Kolkata, Europe/Kyiv accepted).version.checkUpdateis admin-only with a 5s timeout and a per-isolate cache (6h ok / 10min failure;forcereuses an answer under 60s old; concurrent misses share one request), returningstatus: 'ok' | 'unavailable'plusreason.- Web: root layout metadata is static (profile/SEO/OG/favicon metadata
moved to
app/page.tsx); block icons resolved server-side;AdminBadgeloads only with a session cookie; an API outage on/rendersapp/error.tsxwith HTTP 500; the service worker is/sw.js?v=<version>with cachelinkden-v4-<version>, navigation preload and 2xx-only caching;/ogruns on Node with the Workers Cache API (anonymous requests always render the stored profile); the CSP (contentSecurityPolicy()) allows the CAPTCHA providers;/sendsAccept-CH/Critical-CH: Sec-CH-Prefers-Color-Scheme. - Admin:
useSettingsFormsaves only changed keys and keeps unsaved edits across refetches; one module-level unsaved guard (beforeunload, capture click, Navigation APItraverse);PreviewColumnkeeps the block editor mounted while collapsed; every admin route has a serverlayout.tsxtitle; login honours?from=viasafeAdminRedirect. @linkden/env/webno longer uses t3-env or zod (same import path and shape, still validates at import/build time;SKIP_ENV_VALIDATIONhonoured).- Email:
ContactNotificationPropshaswhereMetandblockTitlein place ofsubject(phone and company removed); the footer links to the GitHub repo; Resend sends time out after 10s and throwFailed to send email (<status>): <message>without the API key. An unsetemail_providermeans Resend; other providers never send. - UI:
Alertno longer defaults torole="alert"; the sidebar shortcut is Cmd+B / Ctrl+B (ignored in editable fields, disabled below md);SidebarMenuButtonsetsaria-current="page"; Switch/Checkbox disabled styles key offdata-disabled. Security upgrade: next 16.3.8, OpenNext 1.20.9, hono 4.13.13. - Admin console rebuilt on shadcn/Base UI with five destinations and
path-segment sub-tabs; legacy routes 307 from
proxy.ts.
Removed
- Public
GET /api/wallet-passand the footer wallet button (download is nowGET /api/admin/wallet-pass, session-gated). - tRPC procedures
public.getVCard,public.getBranding,analytics.recentClicks,analytics.purgeExpiredData,blocks.get,forms.get,version.current,wallet.applyPreset. Analytics inputs accept only{ period: 7d | 30d | 90d | all }. .github/workflows/check-social-icons.ymland its script/data..p12uploads for wallet signing keys.
Fixed
- Contact forms missing from public pages after upgrading (legacy
form/contact_formrows). - The footer vCard download 404ed when the page had no vCard block.
bun reset:passwordwrote a hash Better Auth could not verify and only touched one local store;bun reset:factoryreported success after failing and was not atomic.- Enabling 2FA locked the admin out (no login step); the first magic-link sign-in could delete the admin's password (unverified email).
- Theme overrides only applied in light mode; Insights trends showed
+100%for "All time" and dates shifted west of UTC. - Social URLs doubled their prefix when a profile URL was pasted.
Security
- Same-origin writes: POSTs to
/trpc/*and/api/uploadmust come fromCORS_ORIGIN(normalized) or the request's own origin, with aSec-Fetch-Sitefallback, else 403. Body limits: 64KB on/api/auth/*, 1MB on/trpc/*, 16MB forbackup.*. CORSallowHeadersincludex-setup-token. - Auth tokens never reach logs (path-only request line, invocation logs off); visitor PII is stripped from logged SQL errors.
- Inbox
mailto:links are percent-encoded so a stored contact email can't add Bcc/Subject headers. - tRPC error responses no longer carry stacks or internal messages.
0.5.0 β 2026-08-21
β¨ What's new
- LinkDen deploys for real now. The production deploy pipeline works
end to end on Cloudflare. Your site and its API live on one hostname
(for example
https://links.yourdomain.comwith the API under/api/*), so there is no cross-site cookie juggling and admin login no longer bounces you back to the login page. - Custom domain in one setting. Set
SITE_DOMAINand redeploy. LinkDen attaches the domain to the web app and routes the API under it. No dashboard clicking, no manual DNS records. - Daily database backups. A scheduled job exports the D1 database
every day into a private R2 bucket (
linkden-backups), kept for 30 days. Restore steps are in the troubleshooting docs. - You will know when email is not set up. The dashboard shows a banner when no email API key is configured (password reset and magic links need it) with a direct link to the Email settings tab.
- Wallet pass editor, iOS style. The Apple Wallet builder now looks and works like the Wallet app: a centered pass preview with a bottom tab bar (Business, Images, Context, Colors, Background), live AA contrast badges on custom colours, and an "Unsaved" chip so you never lose an edit.
- Better keyboard and screen-reader support. Bottom sheets (mobile preview, block editor, connections detail) trap focus, close on Escape, and restore focus when dismissed. Every admin page has one real heading. Colour fields warn you when text will be hard to read and offer a one-tap fix.
- Theme preset names. "Light" is now "Classic" and "Dark" is "Graphite". Your saved choice is unchanged.
- Contact form delivery works. Email, database, and "both" delivery modes now do what they say. Submissions are stored before the email goes out, so a mail failure never loses a message.
- Password reset works. The reset link on the login page now calls the right endpoint.
- Faster admin sessions. Session lookups for page loads are cached in the cookie for one minute, so admin pages stop hitting the database on every request. Anything that changes data still checks the database, and changing your password now signs out every other browser.
π§ Technical
Added
- Same-origin routing model in
packages/infra/alchemy.run.ts: the web worker (linkden) ownsSITE_DOMAINas a Cloudflare Custom Domain; the API worker (linkden-api) gets RoutesSITE_DOMAIN/api/*andSITE_DOMAIN/trpc/*. Routes run before the custom-domain worker, soBETTER_AUTH_URL,NEXT_PUBLIC_SERVER_URL,CORS_ORIGIN, andNEXT_PUBLIC_SITE_URLare allhttps://SITE_DOMAINin production. UnsetSITE_DOMAINdeploys toworkers.dev(staging). - Service binding
APIfrom the web worker to the API worker. SSR fetches go throughapps/web/src/lib/server-api.ts(apiFetch), because a workerfetch()to its own zone bypasses routes. Falls back toNEXT_PUBLIC_SERVER_URLin local dev. NEXT_PUBLIC_SITE_URL(validated withz.url()inpackages/env/src/web.ts) drivesmetadataBase,robots.ts, andsitemap.ts. Theexample.comfallbacks are gone; thelocalhostdefault only applies outside production builds, andalchemy.run.tsfails the deploy when the variable is empty or does not matchhttps://SITE_DOMAIN.- Explicit Alchemy stage (
ALCHEMY_STAGE, defaultprod) and resource names:linkden-db,linkden-images,linkden,linkden-api,linkden-backups(R2, 30-day lifecycle expiry). Non-prodstages get the stage as a name suffix so adevdeploy is independent of production. Upgrade note: installs deployed before this release have Alchemy-generated names (linkden-database-runner,linkden-images-runnerfrom CI); setLINKDEN_DB_NAMEandLINKDEN_IMAGES_BUCKETto those before deploying or theprodstage creates an empty database and bucket. See the Cloudflare self-hosting guide. .github/workflows/backup-db.yml: dailywrangler d1 exporttolinkden-backups/d1/<date>.sql.- Structured JSON error logs: Hono
app.onError, tRPConError, and atry/catcharoundscheduled()inapps/server/src/index.ts. Worker invocation logs enabled viaobservability. /api/healthreportsemail: configured | missingnext todatabaseandversion.- Dashboard email-missing banner linking to
/admin/settings?tab=email(the settings page now reads?tab). - Tests:
apps/server/src/__tests__/health.test.ts,apps/web/src/app/__tests__/sitemap.test.ts,packages/auth/src/__tests__/cookie-config.test.ts(Secure, HttpOnly, SameSite=Lax, noDomain=). - Shared
Sheetprimitive (Base UI Dialog) with abreakpointprop that auto-closes past its breakpoint; replaces three hand-rolled bottom sheets (#51). ColorFieldcontrastAgainstprop: live WCAG ratio badge and a "Fix" chip backed bygetReadableTextColor; wired into appearance custom colours and block custom bg/text colours (#51).Tabs variant="bar"(icon over label, roving tabindex) for the iOS style wallet editor (#52).@linkden/db/testingin-memory libsql helper (createTestDb) for integration tests;settings-registry.tsdrift test; CAPTCHA, upload-signature, retention, embed-registry, and batch-rollback tests (#53).- Daily retention cron (
scheduled()handler) pruning analytics, sessions, contacts, and audit rows, plus an orphan R2 image sweep with a 24h grace window (#53). - Contact form delivery (
email/database/both) throughpackages/email(#53). - CI security job: OSV scanner plus a CycloneDX SBOM artifact
(#53). The scan is
report-only for now (open advisories are all dev-tooling transitives);
osv-scanner.tomlis in place for dated ignores once it is switched to blocking.nextbumped to 16.2.11 andhonoto 4.13.3.
Changed
apps/web/src/app/api/og/route.tsxmoved toapp/og/route.tsxso the OG image route no longer collides with the/api/*Worker route.layout.tsxand the SEO settings section point at/og.- Better Auth
session.cookieCacheenabled (maxAge: 60, options inpackages/auth/src/auth-options.ts, shared with the cookie test).createContextand/api/uploadpassdisableCookieCachefor non-GET requests, so mutations always read the session table and a factory reset or revoke takes effect immediately; GET reads may lag by up to 60 s.changePasswordsendsrevokeOtherSessions: true. Stale cross-subdomain cookie comments removed frompackages/auth/src/index.ts. - Deploy workflow: Node version pinned via
.node-version(24),setup-nodeSHA-pinned,SITE_DOMAINandNEXT_PUBLIC_SITE_URLread from environment variables, both added toturbo.jsonpassThroughEnv. Docs deploy builds withNEXT_PUBLIC_BASE_PATH=/linkden. - Alchemy state moved from the deprecated
D1StateStoreto the sharedalchemy-stateCloudflareStateStoreworker;ALCHEMY_STATE_TOKENis now required at deploy time (#54). - Alchemy runs under Node via
tsx(Bun segfaults on the Alchemy program, exit 132). Deploy secrets declared inturbo.jsonpassThroughEnvso Turborepo strict env mode passes them through (#55). - Wallet editor rebuilt around a
DeviceFramepreview hero with an in-flow translucent sheet; state model, tRPC contract, andwallet_*keys unchanged (#52). - Dashboard and appearance headers render via
PageHeader;DesktopTopBarlabel demoted to a non-heading kicker so each page has oneh1(#51). - Theme preset display labels: "Light" β "Classic", "Dark" β "Graphite" (persisted keys untouched) (#51).
- Settings metadata (sanitizer kind, max length, secret/mask, backup
policy) consolidated into
packages/validators/src/settings-registry.ts; settings, wallet, and backup routers derive from it (#53). - Backup import, block reorder, bulk settings, wallet config, and danger
resets run as a single
db.batch([...])(#53). - Version read from
version.jsononly, compared withcompareSemver(#53). - Next 16
middleware.tsrenamed toproxy.ts; tsdownnoExternalreplaced withdeps.alwaysBundle; dead Wallet "coming soon" branch removed (#53). - Shared email, wallet JSON parsing, and embed-provider helpers
(
parsePass*Json, one embed registry in validators) (#53). check-typesscripts in every code workspace;@vitest/coverage-v8with a coverage floor enforced invitest.config.ts(#53).
Removed
- Docker, Coolify, and Railway deployment path and docs. Deployment is Cloudflare-only (#53).
/qrentry dropped fromsitemap.ts.
Fixed
- Password reset on the login page called a nonexistent endpoint on the
web origin; it now uses
authClient.requestPasswordResetagainst the API (#53). - Deploy workflow checks out
workflow_run.head_sha, the exact commit that passed CI (#53). settings.getAllmasked three of six secrets; wallet signing certs were returned in plaintext (#53).audit_logwas skipped by full and factory resets (#53).- Wallet "Unsaved changes" chip failed AA on the translucent panel; keyboard focus is kept when switching editor tabs (#52).
- Dashboard chrome used hardcoded blue/violet (about 2.5:1 on the day theme); consent banner "Accept Selected" and footer pills now read on light presets (#51).
- Setup wizard surfaces real server errors instead of swallowing them (#51).
- Backup imports are sanitized; hex-alpha styling guarded
(#51). Imported
blocks and social networks now go through the same gate as
blocks.create/social.updateBulk(validateBlockImport,socialNetworkUpdateSchema): http(s)-only URLs, icon format, per-type config. Invalid rows are skipped and counted (skippedin the result,backup.import.skipped_rowsaudit entry) instead of landing as rawjavascript:hrefs on the public page. The LinkStack importer drops non-http(s) links the same way. - Clearing a block's URL, icon, embed URL, or embed type in the builder
now persists: the editor sends
""and the schema'sclearablefields turn it intonullfor the row (packages/validators/src/blocks.ts). vcardConfigSchemais derived fromvcardDataSchema, so a vCard block the builder accepts always passes the stricter download-path parse (/api/vcard,public.getVCard) instead of silently 404ing.- Public renderers guard stored URLs (
safeHttpUrl) and look up heading levels / icon names withObject.hasOwn, so an importedheadingLevel: "__proto__"can no longer crash the server render.
Security
- Single-admin invariant enforced by a
BEFORE INSERTtrigger onuser(0007_single_admin_trigger.sql), not just middleware (#53). - Analytics and CAPTCHA use server-derived
requestMeta(cf-connecting-ip,cf-ipcountry, UA, referer); CAPTCHA provider is an enum that fails closed, with a 10s timeout,remoteip, and hostname/action verification. Better Auth IP header iscf-connecting-ip(#53). - Uploads:
Content-Lengthrejected before buffering, magic-byte signature must match the extension, replaced R2 objects deleted (#53). - Rate limits on
/api/auth/request-password-resetand/api/auth/reset-password(#53). - Every GitHub Action SHA-pinned with minimal
permissionsblocks (#53). - Danger resets, wallet config/preset/signing-key changes, and block deletes are written to the audit log (#53).
0.4.0 β 2026-05-10
β¨ What's new
- Build a proper Apple Wallet pass. New pass builder lays out your card like a real membership pass β header, primary line, secondary fields, image slots, signing keys, and a live preview as you type.
- Crop every image you upload. Drag a photo in for your avatar, banner, OG image, or wallet logo β a built-in cropper opens with the right shape pre-set so nothing comes out stretched.
- Cleaner settings page. Settings are now organised into tabs at the top with a sticky save bar at the bottom β no more scrolling to find the save button.
- Round or rounded-square social icons. Pick which shape suits your page in Appearance.
- Big-photo "hero card" layout. New public-page layout puts a tall avatar card up top and stacks your blocks beneath it.
- Analytics page inside the admin. See views and clicks without leaving the dashboard.
- Custom login screen. The split-panel login is now fully whitelabel β your logo, your colours, your background image.
- Setup wizard. First-time admins get walked through account, profile, and theme on a single guided screen.
- Contact-form inbox. All visitor messages land in a new
/admin/connectionspanel with read/unread state. - Account page redesign. Profile, email, password, and danger zone are now in calm stacked sections instead of one long form.
π§ Technical
Added
- Wallet pass builder (pass.mk-style) with Apple HIG field limits (header / primary / secondary / auxiliary / back), template presets, image slots, signing-keys section.
- Universal
ImageCropDialogwired intoImageUploadFieldwith per-purpose presets. - Admin settings UX: top tabs + sticky save bar.
social_icon_shapesetting (circle|rounded-square).- Hero-card public-page layout, single-column blocks.
/admin/analyticspage + shared analytics widgets.- Split-panel login, sidebar setup wizard, custom login branding.
/admin/connectionscontact-form inbox.- CI workflow: lint / type-check / test / format / build gate.
- Unit tests for wallet + settings validators, sanitize utilities, and upload validation β focused on security and data contracts.
- Vitest v8 coverage config +
bun run test:coveragescript.
Changed
/admin/accountredesigned as stacked sections.- README +
CLAUDE.mdrefreshed for the current monorepo layout. - Upload validation extracted into
apps/server/src/lib/upload-validation.tsfor unit testing. - Patched + minor dependency upgrades across workspaces.
Fixed
- Narrowed
getLoginShaderPresetreturn type toShaderBannerPreset.
Security
- Per-route Cloudflare rate limiting (
RL_AUTH,RL_STRICT,RL_UPLOAD,RL_PUBLIC). - Triple-check upload validation: size β€ 5 MB, extension allow-list, MIME allow-list, purpose allow-list.
- Single-user signup lock on
/api/auth/sign-up. - Magic-link runtime gate via
magic_link_enabledsetting. - Security headers on every response (HSTS,
X-Content-Type-Options,X-Frame-Options,Referrer-Policy,Permissions-Policy). - R2 image-serving path-traversal guard.
0.3.0
β¨ What's new
Baseline release. Public profile page, six block types (link, header, embed, connect, vCard, location), block-level analytics, admin appearance / SEO / branding panels, vCard export, Apple Wallet pass MVP, Apple Maps embed for Location blocks, Cloudflare Workers + D1 hosting.
π§ Technical
Documentation sync at commit 1691cc5. Stack: Hono on Workers, tRPC
v11, Drizzle ORM on D1, Better Auth, Alchemy IaC for deployment.