Skip to content

release: v1.18.0#317

Merged
TemujinCalidius merged 13 commits into
mainfrom
dev
Jul 21, 2026
Merged

release: v1.18.0#317
TemujinCalidius merged 13 commits into
mainfrom
dev

Conversation

@TemujinCalidius

Copy link
Copy Markdown
Owner

v1.18.0 — merge devmain (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 operators upgrading from before v1.18.0 must run docker compose cp app:/app/public/uploads ./public/uploads BEFORE rebuilding — the new bind mount would otherwise shadow, then destroy, their existing uploads.

🤖 Generated with Claude Code

TemujinCalidius and others added 13 commits July 19, 2026 12:03
#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>
@TemujinCalidius
TemujinCalidius merged commit e79a934 into main Jul 21, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant