release: v1.18.0#317
Merged
Merged
Conversation
#59) (#297) The analytics site-id shipped web-side in v1.17.0, but the API *key* — which gates the in-app dashboard, kudos and leaderboard — was still env-only. This moves it web-side too, the last env-only piece of the analytics story. - New src/lib/analytics-secret.ts mirrors the crosspost-credential pattern: the key is stored AES-256-GCM-encrypted (secret-box, key from ADMIN_SECRET) as a SiteSetting row under the integration.* namespace (excluded from SITE_CONFIG_KEYS), read DB-first with an env fallback, never returned to the client. - Dedicated /api/admin/analytics-key route (verifyAdmin + verifyOrigin) sets/ clears the key and reports status only — the key is never echoed back. - tinylytics.ts now reads the key + site id at RUNTIME (was module-level process.env consts baked in at import), so the dashboard honours a web-set key AND site id, not just env vars. isTinylyticsConfigured() is now async. - UI: the existing Analytics section of Site settings gains a write-only password field + Save/Clear + a configured (db/env) badge, disabled with a note when ADMIN_SECRET isn't set. Tests: analytics-secret round-trip/env-fallback/rotation + route auth/secret- never-returned (542 pass). No CSP change (tinylytics.app already allowlisted). Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… (#298) Generalizes the region×variant contract past the feed: the header is now a swappable layout region with three variants — bar (today's sticky top bar), centered (a masthead: name over a centered nav row), and minimal (name + a menu button). This is the reusable groundwork the sidebar/"Classic Blog" shell (a later phase) builds on. - themes: HeaderVariant type + header in LayoutConfig; LAYOUT_REGIONS.header, HEADER_VARIANTS, isHeaderVariant; resolveLayout resolves feed + header independently. Both built-in themes preset header: "bar", so every existing instance renders identically. - config: layout.header field + validator + overlay in site-settings (mirrors layout.feed), LAYOUT_HEADER env default in site.config.ts. - render: new SiteHeader dispatcher in the root layout (bar → the unchanged Navbar; centered/minimal → new components reusing the shared nav data + NotificationBell + MobileMenu). MobileMenu gained an alwaysCollapsed prop for the minimal header. - surfaces: Header layout selects in Admin → Site settings → Appearance and the first-run setup wizard. Tests: resolveLayout/isHeaderVariant + validator + overlay (548 pass). Verified the centered variant renders live via the dev server (env fallback). Default (bar) is byte-identical. Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Both verified green in daily triage and re-verified here: tsc, 548 tests, eslint 0, next build all pass. sharp deliberately NOT bumped (held at ^0.34.5): next@16.2.10 declares optionalDependencies.sharp "^0.34.5", which 0.35.x doesn't satisfy, so npm would install a second nested copy and a duplicate libvips native binary — undetectable by tsc/tests/build. Confirmed single copy @0.34.5, no nested copies, after this bump. typescript stays at 6.x (blocked, #234). Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Adds `footer` to the region×variant contract alongside `feed` and `header`, with three variants: row (today's credit/badges/links row), minimal (a single quiet line), and columns (a sitemap footer with sections, connect links and credit). - themes: FooterVariant + LayoutConfig.footer; LAYOUT_REGIONS.footer, FOOTER_VARIANTS, isFooterVariant; resolveLayout resolves all three regions independently. Both built-in themes preset footer: "row", so every existing instance renders identically. - config: layout.footer field + validator + overlay (mirrors layout.header), LAYOUT_FOOTER env default. - render: new SiteFooter dispatcher (row → the unchanged Footer; minimal/columns → new components) fed by a shared footerData loader; the root layout now resolves the layout once and passes header + footer variants. - surfaces: Footer layout selects in Admin → Site settings → Appearance and the first-run wizard. Every variant keeps `mt-auto` so the sticky-footer contract holds. Footer.tsx is untouched. Tests: isFooterVariant + resolve/inherit/override + all-three-regions independent + overlay + validator (554 pass). Verified LAYOUT_FOOTER=columns renders server-side (grid-cols-3 + Sections/Connect present, row markup absent). Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…te SITE_URL (#59) (#301) The wizard claimed the setup slot (setupDone=true) BEFORE writing .env.local, so any failure in between — an unwritable install dir, a root-owned .env.local, a Docker bind-mount, or a malformed siteUrl — left the instance "set up" with no ADMIN_SECRET. The proxy then redirects every page to /setup, which refuses with "Setup has already been completed": bricked, recoverable only by hand-editing files or SQL. Exactly the friction #59 exists to remove. Server (api/setup/route.ts): - Reorder to validate everything (incl. the URL parse) → claim → write, and roll the claim back to setupDone=false if the write throws, so setup is retryable. Reopening the claim window is correct: the write failed, so there's no admin secret to protect yet, and bricking is strictly worse. - Validate SITE_URL properly: http(s) only, real host, no credentials, no path/query/fragment; normalized to a bare origin. Previously `javascript:...` passed and produced FEDI_DOMAIN="". - Caller-fixable failures now throw a SetupValidationError → 400 (a malformed siteUrl used to fall through to an opaque 500). Wizard (setup/page.tsx): - The public site address is an editable field instead of a silent window.location.origin guess (wrong behind a proxy/tunnel), with warnings for localhost / private-range / plain-http origins. - New "Review & confirm" step before anything is written, listing every value incl. the full @handle@domain and the derived federation domain. This also fixes the off-by-one step labels (5 steps were labelled "of 6"; review is now the genuine 6th). - Handle previews now show the real derived domain, not the browser hostname. Adds src/lib/__tests__/setup-route.test.ts — the first coverage this route has ever had (13 tests: token gate, URL rejections, claim ordering, rollback, 403). 567 pass. Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The structural groundwork the sidebar / "Classic Blog" layout needs, plus the fourth themeable region. Why a route group: a site-wide shell has to wrap ONLY visitor-facing pages, but the root layout is shared with /admin, /setup, /compose, /timeline and /search — and a server layout can't read the pathname. Route groups are the mechanism; URLs are completely unchanged. (A middleware x-pathname shortcut was rejected: the root layout isn't re-rendered on client-side soft navigation, and there are live <Link href="/timeline"> paths out of public pages, so a shell would silently persist onto admin screens.) - Moved the 15 public page files into src/app/(public)/ (git-tracked renames). admin/, setup/, compose/, timeline/, search/ (admin-gated), api/, ap/, .well-known/, feed.xml/, rsd.xml/, xmlrpc/, uploads/, manifest.ts and the root layout all stay put. Verified zero import fixes were needed: every moving file imports via the @/ alias, and the 8 upward relative imports in the codebase all live in files that don't move. - New src/app/(public)/layout.tsx applies the `shell` region: "normal" renders NO wrapper (default → byte-identical), "narrow" clamps the column and composes over each page's own max-w (narrower wins), so no page edits were needed. - themes: ShellVariant + LayoutConfig.shell, LAYOUT_REGIONS.shell, SHELL_VARIANTS, isShellVariant, resolveLayout branch; both themes preset shell: "normal". config: layout.shell field/validator/overlay + LAYOUT_SHELL. - Admin + wizard "Page width" selects. `wide` and `sidebar` deliberately not included: `wide` first needs the 21 hard-coded max-w-* across 14 pages centralised, and `sidebar` needs the Sidebar region blocks. Both are follow-ups. Verified: cold `next build` green with zero "(public)" in any route path, all public routes still present, tsc clean on regenerated route types, 573 tests. Live-checked LAYOUT_SHELL=narrow emits the wrapper and the default emits none. Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…double the suite (#303) (#304) vitest.config.ts declared no `include`, so Vitest fell back to its default glob across the whole repo root. Any nested checkout under the project directory — a git worktree, a second clone, a backup copy — had its tests collected and run alongside the real ones, so `npm test` silently ran the suite TWICE. Because the nested copy is a different commit, that can fail on code the contributor never touched, or pass on code they just changed. (`coverage.include` was already scoped to src/lib/**, but that only narrows coverage REPORTING — it does not constrain collection.) Scoped `include` to src/ (anchored at the root, so `.wt-test/src/**` no longer matches) and extended the default `exclude` for nested checkouts and .next, since Vitest's defaults only cover node_modules/dist/cypress/.{idea,git,...} — none of which a worktree matches. Verified empirically: with `git worktree add ./.wt-test HEAD` present, discovery was 128 files before and is 64 after, with zero .wt-test paths collected; the suite still passes 573/573. Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
) Adds `sidebar` to the shell region: public content beside a column of about/bio, recent posts, sections and connect links. This is the last remaining blocker for the Classic Blog theme (the issue annotates the shell row "sidebar (needed for Classic Blog)"). - themes: "sidebar" joins ShellVariant + LAYOUT_REGIONS.shell. site-settings needed NO change — isShellVariant already gates layout.shell. - Both built-in themes stay shell: "normal", so it renders ONLY when the owner opts in and every existing site is untouched. - New Sidebar.tsx + sidebarData.ts, the latter copying the footerData loader idiom (one Promise.all over already-cached sources). Reuses getRuntimeProfile for about/bio, buildNavLinks for sections (so it honours every nav.* toggle), and siteConfig.fediAddress + footer/contact for connect. The single new query (5 recent posts) mirrors the homepage filter and is .catch()-guarded so a DB hiccup degrades the block instead of the page. - (public)/layout.tsx gains the sidebar branch, reusing the exact two-column grid + breakpoint already proven at photography/[slug] so it collapses on mobile consistently. - Admin + wizard selects gain "Sidebar". No tags block: there's no public /tags/[tag] route (search is admin-gated), so those links would go nowhere. It ships with a public tag route later. Tests: the two "later phase" guardrails in themes.test.ts are FLIPPED (not deleted) — isShellVariant("sidebar") and the resolveLayout override now assert the shipped behaviour, with "wide" still pinned as unbuilt. Added an explicit assertion that neither theme opts in. 574 pass. Verified live: LAYOUT_SHELL=sidebar renders <aside> + the grid + each block on /about; the default emits no sidebar at all. Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
updateProfile has always accepted authorName/authorBio/authorTagline/ actorSummary/avatarPath/bannerPath — with validation and actor-Update federation — but NO web UI ever wrote any of them. /about renders authorBio, so until now the only way to set your bio was hand-editing .env.local on the server: exactly the friction #59 exists to remove. - New "Your profile" section in Admin → Site settings: display name, tagline, bio, Fediverse summary, plus avatar + banner with upload and Revert to default. Uploads reuse POST /api/media UNCHANGED (the owner cookie satisfies its media scope, and it returns an absolute URL that imagePath already strips our origin from), following ComposeClient's upload pattern. - api/admin/_actions/profile.ts: ""/null on avatarPath/bannerPath now CLEARS back to the built-in default instead of 400ing. Storing "" is what "use the default" means here — site-profile reads `row.avatarPath || base.avatarPath`, so it reverts AND keeps tracking the default rather than pinning a permanent override. Mirrors the ""/null = inherit convention themeAccents already uses. - admin/site/page.tsx already awaited getRuntimeProfile(), so this adds zero queries — just widens the props and passes the built-in defaults. The save is DIRTY-DIFFED, and that's load-bearing rather than an optimisation: FEDERATED_FIELDS is guarded by `field in data` (presence, not a value diff), so sending the whole profile on every save would blast an actor Update to every follower on every settings save. Same class of bug as #276's accent fix, at a new call site — covered by a test asserting local-only fields never federate. Tests: clear-to-default writes "" (via both "" and null) and still federates (it changes the actor doc); local-only fields do NOT federate; bio/summary round-trip with 400 on newlines and >500 chars; and site-profile pins the "" → /images/avatar.png read contract the revert depends on. 579 pass. Not exercisable here (no DB/browser): actual federated delivery, the sharp re-encode and the disk write. The media route itself is unchanged. Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…isma 7.9.0 (#311, #312) (#313) Both highs were stale lockfile pins, not upstream blocks: - js-yaml GHSA-52cp-r559-cp3m (quadratic-CPU DoS via YAML merge keys) — the existing override "js-yaml": "^4.2.0" already PERMITTED 4.3.0; the lockfile was simply pinned to 4.2.0. Now resolves to 4.3.0. - brace-expansion GHSA-3jxr-9vmj-r5cp (exponential-time DoS) — dev-only. Also removed `gray-matter`, which was declared as a PRODUCTION dependency but imported nowhere in the tree (verified: zero references in src/, scripts/ or root configs; npm ls showed it as a direct dep with no dependents). It was the only path pulling js-yaml into the runtime tree — so rather than bumping a transitive we never call, the dependency is gone and js-yaml is now reachable only via @eslint/eslintrc, i.e. dev-only. Prisma bumped to 7.9.0 with the client, CLI and pg adapter kept in lockstep (adapter-pg was still on 7.8.0), then `prisma generate` re-run. sharp deliberately untouched (^0.34.5 — matching Next's optional pin; 0.35.x would install a duplicate libvips binary that tsc/tests/build cannot detect). typescript untouched (#234). Verified after the change: single sharp @0.34.5, no nested copies. npm audit: was 3 moderate + 2 high → now 3 moderate / 0 high (the remaining moderates are the known postcss-inside-next chain tracked in #12). Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…troying media (#308) (#314) The `app` service declared NO volumes, so every uploaded file — post images, photo galleries, audio (up to 100MB each), avatars/banners and the proxied Fediverse media cache — lived only in the container's writable layer. That made it far worse than an "update" bug: any recreate destroyed them. `docker compose up --build`, `--force-recreate`, a `docker system prune`, or simply a HOST REBOOT. And because the database stores file PATHS rather than blobs, the rows survive: the site comes back looking intact with every image placeholder and audio player 404ing. Silent, and invisible in logs. - docker-compose.yml: bind-mount ./public/uploads:/app/public/uploads on `app`. A bind mount rather than a named volume on purpose — it puts media in the operator's own checkout where it's visible and backup-able, matching where a bare-metal install keeps it. Safe: the Dockerfile sets no USER, so the container runs as root and writes succeed. - .dockerignore: exclude public/uploads. It wasn't excluded, so `COPY . .` baked a host copy into every image layer — bloating images and masking the bug for anyone who'd run bare-metal in the same directory first. - Setup wizard: detect containerisation (/.dockerenv or /proc/1/cgroup) and warn on completion. The wizard writes .env.local to process.cwd() — INSIDE the container — but compose loads env_file from the HOST. Different files. If the wizard generated ADMIN_SECRET and it never reached the host file, the next rebuild loses it while setupDone=true persists in Postgres, and /api/setup refuses with "already completed" — locked out. Now the wizard shows the exact line to copy to the host, while the secret is still on screen. - docs/deployment.md: document what lives outside the database, the required host ADMIN_SECRET, and a prominent one-time rescue step for existing installs. MIGRATION HAZARD (documented in the changelog + release notes): a bind mount SHADOWS whatever is already at that path, so applying this fix without first copying files out would hide an existing operator's uploads and then destroy them on the next rebuild. `docker compose cp app:/app/public/uploads ./public/uploads` must run BEFORE rebuilding. No Docker daemon in this environment, so container behaviour is reasoned from the compose/Dockerfile semantics rather than executed; the compose file is validated as parsing and carrying the volume. Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…identity; document real backups (#309, #310) (#315) Two halves of the same story: your identity row matters, back it up, and we'll tell you if it's gone. #310 — ensureActorKeys() minted a fresh keypair whenever the ActorKeys row was absent, with no guard and no log line. That's correct for a NEW instance. For an ESTABLISHED one a missing row means the keys were LOST (a dropped pgdata volume, a content-only restore, a migration that didn't carry the row), and quietly replacing them ROTATES the instance's federation identity — existing followers still hold the old public key, so outgoing activities can fail signature verification with nothing to explain why. - Detect history via SiteSettings.setupDone OR a FediFollower count; a DB error answers "not established" so a genuine first run is never blocked. - Still mint (never brick the site), but log a prominent error naming the likely causes and the recovery path. - Raise a dismissible admin alert by reusing the existing MaintenanceItem surface that NotificationBell already renders — no new UI. Upserted on a fixed [kind, packageName, latest] so repeat calls don't spam, with an empty `update` so a dismissed alert isn't resurrected. Fire-and-forget, since this sits on a render path, and an alerting failure can't break key generation. #309 — docs/deployment.md documented pg_dump only, but public/uploads is the one category of user data that ISN'T in the database, and the DB stores paths rather than blobs. Following the docs exactly produced a restore that looked intact with every image and audio player 404ing. Backup/restore/migration now cover both halves, and call out ActorKeys as the single most important row — a plain pg_dump includes it, a selective dump may not. Tests (8 new): existing keys returned untouched with no side effects; new instance bootstraps silently; established instance warns AND alerts; followers alone count as history; stable alert key with no dismissal resurrection; an alerting failure still returns keys; a DB failure in the check falls back to silent bootstrap. 587 pass. Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Convert the Unreleased block to 1.18.0 (2026-07-21) and bump package.json + lock, via scripts/prepare-release.mjs. A security-and-durability release on top of the theming work: cleared two high advisories (#311, #312), stopped Docker installs destroying uploaded media (#308), made a lost federation keypair loud instead of silent (#310), and fixed the incomplete backup guidance (#309) — plus the themeable header/footer/sidebar regions, the (public) route group, the admin profile panel and web-configurable analytics. Co-authored-by: Samuel Lison <samuel@slcolonies.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v1.18.0 — merge
dev→main(a true merge, not squash, so history is shared).A security-and-durability release on top of the theming work. Full notes go on the GitHub Release + Announcements discussion.
docker compose cp app:/app/public/uploads ./public/uploadsBEFORE rebuilding — the new bind mount would otherwise shadow, then destroy, their existing uploads.🤖 Generated with Claude Code