• v7.1.0 8843c7a2b5

    v7.1.0
    All checks were successful
    CI / App — typecheck, test & build (push) Successful in 2m4s
    CI / API — typecheck & tests (push) Successful in 3m8s
    Stable

    claude-bot released this 2026-07-17 01:00:38 +00:00 | 109 commits to main since this release

    Tea Leaves v7.1.0 — Core Hardening & Security

    Released 2026-07-16

    A pre-v8 hardening pass: fix the daily-use and recovery paths that were quietly broken, and lock down + test the v7 federation trust boundary before stacking v8's moderation layer on top of it. All 13 issues of the milestone, from the 2026-07-15 codebase audit. No new migrations; no breaking changes.

    🔒 Security

    AP inbox: signer bound to the claimed actor (#82)

    verifyInboxSignature only proved the request was signed by the keyId owner — for every activity type except the entry-write path it never asserted that owner == activity.actor. Anyone controlling a domain could sign a request while claiming to be an arbitrary third party: spoofed fediverse identities in public motif comments, forced Follow rows, a forged Delete/Undo. The binding is now hoisted to run for all activity types (Follow / Invite / Undo / Like / Announce / Create / Delete) immediately after signature verification → 401 on mismatch.

    AP inbox: SSRF guard on remote-controlled fetches (#81)

    The unauthenticated inbox fetched attacker-supplied actor URLs and delivered to attacker-supplied inbox URLs with no host filtering, following redirects — blind SSRF into the homelab. New lib/ssrfGuard.ts resolves the target and rejects loopback / RFC1918 / link-local / ULA / CGNAT, re-validating every redirect hop. Origins an admin has explicitly federated with bypass the denylist, so two homelab instances still federate over a private network (mirrors the #62 outbound allowlist).

    Backup routes are admin-only (#80)

    /api/backup/* sat behind only requireAuth, so any authenticated non-admin (e.g. a project collaborator) could read cloud-storage credentials, manage destinations and stage restores. Now requireAdmin, and GET /destinations returns only the names of extra_env keys — never the values (editing is write-only).

    🛟 Recovery & reliability

    Backup restore actually works (#84)

    applyRestoreEntry omitted project_id (NOT NULL → 23502) and defaulted certainty to 'possible', which isn't in the CHECK set (→ 23514). Every staged entry apply threw — at precisely the moment recovery is needed. Now sources project_id from the staged data (falling back to the active project) and validates certainty, covered end-to-end by an integration test.

    Graceful shutdown (#85)

    Nothing handled SIGTERM, so every deploy hard-killed in-flight import/media/backup jobs, left BullMQ locks lingering, and could strand a backup at status='running' forever. The API now drains workers, then closes the server, Redis and the pg pool, with a 30 s cap. The handler alone was inert: CMD ["npm", "start"] meant npm — not node — received the signal, so it never ran (exit 1, npm error signal SIGTERM). The image now execs node directly and compose runs tini as PID 1, which also reaps orphaned restic/pg_dump/ffmpeg children.

    Backups can't hang forever (#86)

    restic/pg_dump spawns had no timeout and no error handler — an unreachable repo or a missing binary left the promise pending forever, silently blocking every future scheduled backup while the dashboard cheerfully showed "running" (so no alert fired). Spawns now have a 2 h kill-timer and surface spawn errors; a reconciliation pass fails any job still running after 3 h and fires the configured alert.

    Migrations run before workers (#87)

    Workers and the backup scheduler started before runMigrations(), and the scheduler's error was swallowed — on a first-ever boot backup scheduling silently never registered. Boot order is now migrate → workers → scheduler → listen.

    📱 Mobile daily-use

    Quick Capture no longer throws away voice notes (#90)

    Worse than reported: the recorder's target entry was always null while the panel was open, so every Quick Capture voice note was silently discarded on Stop — the feature had no working path at all. Tapping Record now creates the entry up-front so audio always has somewhere to land, and Save updates that entry rather than duplicating it.

    Import, Suggestions & Trash reachable on a phone (#89)

    All three lived only in the desktop header — on <768px no on-screen control reached them. The bottom nav now ends in a More overflow sheet (Import, Suggestions, Trash, Settings — plus Admin/Federation for admins).

    A logged-out user clicking an /invites/:token link landed on /entries with the token gone and no message. The target path now round-trips through the OIDC flow, validated as a same-origin relative path on the way in and out (absolute, //evil.com and /\evil.com rejected) so it can't become an open redirect.

    🐛 Ops / UI

    • Uploads & imports over 1 MB (#83) — the bundled nginx set no client_max_body_size, so its 1 MB default 413'd essentially every image, video and archive import before it reached the API. Now 2g on the /api/ block.
    • PWA install & favicon (#88) — every icon referenced by index.html/manifest.json 404'd; the install prompt and tab icon were broken. Added favicon.svg, apple-touch-icon.png and the 192/512 maskable PNGs.

    🧪 Internal

    API suite grew to 238 tests (from 232): direct HTTP-signature verification tests (valid / replayed / tampered-digest / unsigned-digest / wrong-key / wrong-actor binding end-to-end), SSRF IP-classification tests, backup authorization + restore-apply coverage, and the open-redirect guard matrix — closing the audit's "the newest perimeter is the least tested" finding (#92).

    Upgrading: no migrations and no config changes required. Deploys pick up init: true / stop_grace_period from docker-compose.yml, which the graceful shutdown depends on — redeploy with the compose file from this tag rather than an older local copy.

    Downloads