Changelog
All notable changes to SoundAssist. Newest first. We follow loose Keep a Changelog conventions but without strict semver yet — beta means everything is in flux.
Tags map to git tags. Commit hashes are short SHAs.
The Vault: folder zips survive a missing file (2026-06-24)
Fixed
- A missing storage object no longer breaks a whole backup. If a synced file's upload had silently failed (so its record pointed at nothing), the background zip builder used to crash mid-run — the job hung until a cleanup pass marked it failed with a vague message. Now the builder checks every file exists first and simply skips any that are missing (the rest still zip), and a file that vanishes mid-stream fails the job cleanly with a friendly message. As prevention, finishing an upload now verifies the bytes actually landed before recording the file — so no new "ghost" records get created in the first place.
The Vault: cold-load skeleton + tidy long names (2026-06-24)
Changed
- A cold load now shows a skeleton — a calm pulse of the project-card and file-row shapes that are about to appear — instead of a bare spinner on black, so the wait reads as "loading your stuff" rather than "is this broken?".
Fixed
- Long folder names no longer overflow the backup confirm dialog or the "saved" toast — they're middle-ellipsized (start + tail kept) so the message and its buttons stay tidy.
The Vault: browsing polish (2026-06-24)
Changed
- The Vault landing is an overview again. The root now shows your projects as
cards plus any loose files — a project's contents live one click into its card,
instead of the whole tree flattened into a wall of
.asd/.als/Desktop.inibelow the cards.
Fixed
- Searching "Recent" now finds older files — it used to cap to the 20 newest before applying the search, so older matches were invisible.
- Project-card dates show the year once it isn't the current year, so you can tell "this June" from "last June" at a glance.
- "Play all audio" is now in the right-click folder menu too (it was only on the card's ⋯ button).
- "Snapshots…" is now "Snapshots (restore points)…" so it reads as the versioned restore feature, distinct from the one-off "Download folder (.zip)".
- A small project no longer hides its lone project file behind a closed fold — the "Project files" section opens by default when only a few files sit under it.
- The folder-card ⋯ menu now carries proper menu / menuitem roles for screen readers, matching the right-click menu.
Fixed
- Snapshots panel is now keyboard-accessible — Escape closes it, Tab stays trapped inside, and focus returns where it was on close, matching every other modal in the app.
- Failed builds no longer leak internals to you — a failed snapshot/download used to show the raw error (internal storage paths and all); it now shows a clean message, with the real cause kept in the server logs for debugging.
- The Download button can't silently do nothing — a network hiccup now surfaces an error toast instead of a dead click.
- Double-clicking "Make a snapshot now" won't spawn duplicate builds — if a snapshot is already building for that project, the request joins it instead of kicking off a second multi-GB job.
- "Download folder" can't latch onto a snapshot build — the reuse check is now scoped so a download right after a snapshot still produces a fresh zip.
- No more infinite "Preparing…" spinner — if a build stalls, the panel stops polling after a few minutes and says so rather than spinning forever.
The Vault: folder zips build for large projects (2026-06-24)
Fixed
- "Download folder" and project snapshots failed on any archive bigger than a single ~16 MB part — i.e. virtually every real Ableton project. The cause: Cloudflare R2 requires every part of a multipart upload except the last to be exactly the same length (stricter than S3's "≥ 5 MiB" rule), and the zip streamer was flushing whole accumulated buffers that overshot by the last chunk, so the parts came out unequal and R2 rejected the upload at the final step. The streamer now emits fixed-size parts and lets only the trailing part be short. Found by dogfooding on production; a unit test now exercises the part boundaries (the existing tests mocked R2, so they never enforced the rule). Build failures also now record the real underlying error instead of a generic "couldn't build", so the next one is debuggable at a glance.
Added
- Freeze a project as a dated, downloadable snapshot. A folder's menu gains
"Snapshots…", opening a panel where you can make a point-in-time
.zipof the whole project (files + nested sub-folders) and download any past one; your "restore last week" safety net, distinct from the throwaway "Download folder" zip. The Vault keeps the last few snapshots per project automatically (older ones are reclaimed), so it's a bounded cost, and snapshots build in the background like the folder download. Manual-trigger only for now; nothing snapshots on its own, so there are no surprise storage bills.
The Vault: your projects, as projects (2026-06-24)
Added
- Your work folders now show up as project cards at the top of the Vault. Until now your own (private) folders only appeared as quiet rows in the side column; the main area was a flat list of every file. The Vault root now leads with a "Your projects" grid of cards (size, last-synced, the same open/rename/share/download/move actions), so the page reads as your projects, not a file dump.
- Play all audio from a card. A project card's menu gets "Play all audio" when the folder holds any; it queues every render/stem/bounce in the whole subtree into the mini-player, no opening required. Pure presentation over the existing mirror; no new storage.
The Vault: richer project cards (2026-06-24)
Changed
- Folder cards now show size and when they were last synced. A shared project card reads "12 files; 1.4 GB" and dates itself by its newest file ("last synced") instead of the day it was created, so a card says what's actually in the project at a glance. Computed in one subtree pass (a folder reflects its nested Samples/Renders/… too). Groundwork for the projects grid + play-from-card coming next; no new storage.
The Vault: audio-first folder view (2026-06-24)
Changed
- Open a synced project and you see the music, not the mess. Inside a folder,
The Vault now surfaces the playable audio (wav/flac/aiff/mp3 — renders, stems,
bounces) up top, and tucks everything else — DAW project files, samples, and the
.asd/Desktop.inisidecars a backup faithfully keeps — under a single collapsed "Project files" row. Nothing is hidden from the backup; it's just out of the way until you want it. Folders that are all-audio or all-files render flat as before, and searching inside a folder shows everything so a hit is never buried. First, cheapest slice of the backup+workhorse direction — pure presentation over the existing mirror, no new storage.
Added
- Download a whole folder as one .zip. Right-click a folder (or use its ⋯ menu) → "Download folder (.zip)" to pull the entire subtree — every file and nested sub-folder, structure preserved — for example to restore a lost Ableton project. The archive builds in the background (streamed straight to storage, so multi-GB folders don't blow up memory), downloads automatically when it's ready, and also fires a "your folder is ready" push so you can close the tab. Very large folders are declined up front with guidance to grab the big sub-folders separately, rather than starting a build that can't finish.
Changed
- "Move to" shows only your main folders now. Moving a folder previously listed the entire flattened tree (every nested Samples/Recorded/… sub-folder), which buried the choice. It now lists just the top-level folders; deeper nesting is still available by dragging a folder onto any target.
Work-folder backup: stop dropping DAW + OS files (2026-06-23)
Fixed
- The Vault work-folder sync now backs up everything it scans. The Connect
"work folder" promises a faithful backup ("sub-folders and all"), but the
authenticated attachment upload gate enforced a positive extension allowlist,
so DAW sidecars (
.asd,.amxd), OS cruft (Desktop.ini,.ico), and any other non-whitelisted extension failed with a generic "Couldn't complete that." The authenticated/api/r2/attachment-url+/api/r2/attachment-completeroutes now gate onBLOCKED_EXTSonly (executables + active web content like.exe/.html/.svgstay blocked); everything else is accepted and served asapplication/octet-stream(downloads, never executes). The public, leakable/api/upload/[token]routes keep the strict allowlist. Failed files re-sync automatically on the next pass (they never entered the sync manifest).
The Vault, Phase 2: send it, hear back, get notified (2026-06-22)
The make-or-break share loop got its full surround.
Added
- Web Push notifications. Opt in under your Notifications settings to get a push on this device when someone replies to your work or interacts with you; a second vector alongside the in-app bell and email. Env-gated (VAPID), with a push-only service worker that never touches streaming or auth. (#1175)
- Channel-neutral send. The player and track rows hand off a link over your own channel (Copy link, WhatsApp, or Mail) instead of relying on the OS share sheet. (#1153, #1156)
- Cancel an upload, grab the link on done. Uploads are cancellable, and a finished track offers its share link right away (it is listenable via the token before you publish). (#1171)
- Collect-All reminder. Uploading a DAW project (.als, .flp, .logicx, ...) nudges you to bundle your samples so collaborators do not hear gaps. (#1179)
- Keep the conversation. After an anonymous reply, a quiet invite to make a free account. (#1177)
Security
- Every outgoing email subject is stripped of control characters at the send choke point (header-injection defense). (#1148)
- Per-user rate limit on push subscription, plus an adversarial review of the whole Phase 2 surface with the findings fixed. (#1182)
[0.1.2] - 2026-06-16
Changed
- The Circle is now the landing page. Anonymous visitors hitting
/see The Circle front door directly — the upload form, the pitch, the community invite — instead of the LandingBillboard. No redirect; rendered in-place inside AppShell so the sidebar and navigation remain visible. The/circleroute continues to render the same component unchanged.
Shared Folders hub — /shared-maps is now for collaborating on folders (2026-06-02)
The old "My Relationships" roster (a permanent "dedicated room" per client/peer/collaborator) is retired. The Shared Folders tab now does what its name says: make folders you co-own with other people, invite them in, and work on the files together — in a live session or anytime.
What a user notices
- "Shared Folders" in the sidebar (was "Shared Maps"). It lists the folders you share — "Shared by you" + "Shared with you" — as cards.
- Create + invite in one flow. "New shared folder" → name it → the invite panel opens right away.
- Two ways to invite: search a person by name or @handle and add them instantly, or invite by email (they get a join link). Every member can add + download files; new uploads show up for everyone.
- One system, two places. Folders shared during a live session and folders made here are the same thing — no second mechanism to learn.
Under the hood
- /shared-maps rebuilt on
listFolders+folder_members+shared_folder_invites— no new tables, no migration. NewAddMemberSearch(reuses/api/profiles/search) +NewSharedFolderModal. - Removed the dead relationship-roster code the tab no longer uses:
ClientCard, the/shared-maps/[clientId]detail route, and the orphanedlib/clientsCRUD wrappers (updateClient / updateClientRole / deleteClient / ensureClientRootFolder). Therelationshipstable, the /music dashboard, and the session/upload backend are intentionally left for a later, deliberate retirement. - Deferred: a "start a live session from a shared folder" bridge — it threads through the live-session core and needs two-participant verification, so it's a separate, careful step.
v3.0.5 — DB layer closed, OG fixes, bundle ratchet, undoable deletes (2026-05-27 evening)
Second push of the day after v3.0.4 landed. Closes the database hardening layer per the plumbing → DB → backend → frontend volgorde Andre keeps, adds perf + safety scaffolding, and fixes the WhatsApp share-preview bug that Andre reported live.
What a user notices
- WhatsApp / iMessage / Slack share previews work end-to-end.
Three real bugs landed in one round of fixes: the Next.js 16
params-Promise shape change broke every OG image generator
(params.token was always undefined → fallback rendered); the
PostgREST
host:profiles!tracks_host_user_id_fkeyshortcut failed silently because tracks.host_user_id references auth.users(id) not profiles(id); and the rendered PNG was 780 KB which WhatsApp's link-preview fetcher silently drops above ~600 KB. Now: 500 KB PNG with artwork + title + artist. (a29a143 + 7b17fa0 + ddb90e2) - Two browser tabs no longer play through each other. When you press play in tab B while tab A is playing, tab A pauses automatically. Implemented via a BroadcastChannel courier in lib/playback-coordinator.ts; wired into PipPlayer (dashboard) + the /listen WaveSurfer player. (cb065b5)
- Undo for destructive actions. Delete track, delete folder,
leave shared folder, delete post, delete comment — all now show
a 5-second "Undo" toast instead of an immediate destructive
commit. If you click Undo within the window, the row comes back
- no server-side mutation fires. Otherwise the actual API call runs. (36806ec + 9db572c)
- Dashboard activity feed updates live. Was: one fetch on mount, never refreshed. Now: Supabase Realtime channel + 30s poll fallback when the channel can't connect. Same hook applied to the dropdown feed. (0d7b3bf)
Behind the scenes — database layer
- PostgREST schema-cache reload policy (P10.15) — docs/migrations-policy.md captures the full apply flow (Supabase SQL Editor → npm run db:types → NOTIFY pgrst → smoke query) with the 2026-05-27 PGRST205 incident as the worked example. (379d2b1)
- Schema drift detector (P10.16) — scripts/audit-schema-drift.mjs replays migrations in SQL text order, parses lib/database.types.ts via brace-counting, fails CI when the types file diverges from the migrations folder. Caught one real-but-acceptable orphan during the initial run. (e41f55d)
- GDPR Article 17 cascade audit (P10.17) — scripts/audit-gdpr-cascade.mjs cross-references every FK to auth.users / public.profiles with lib/account-deletion.ts. Every user-PII column must be covered by ON DELETE CASCADE, an explicit delete call, or an EXEMPT entry with reason. 41 FKs, all covered. (0cc8615)
- DB-audit gate restored (P10.18) — audit:db-indexes had been excluded from audit:all with a stale comment. Root cause was a SQL comment containing a semicolon truncating the CREATE TABLE body regex. Fixed and re-added. (379d2b1)
- Slow-query weekly digest (P10.19) — supabase/migrations/20260527_slow_query_digest.sql enables pg_stat_statements + adds two SECURITY DEFINER helpers (slow_query_top, slow_query_reset). New /api/cron/slow-query-digest runs Monday 09:00 UTC, emails top 5 slowest queries to ENGINEER_NOTIFY_EMAIL, logger.warn on any query with mean > 500ms AND calls > 100. Migration pending Andre to apply. (e239a19)
Behind the scenes — perf + safety
- Realtime resilience hook (P8.1) — hooks/useRealtimeWithFallback wraps the subscribe-or-poll pattern. Slow poll (5min) while the channel is live, fast poll (30s) when it isn't, tab-visibility guarded, immediate refetch on reconnect. Scheduling policy extracted to four pure helpers with 21 unit tests. (0d7b3bf + 8beb3dc)
- next/image migration complete (P12.3) — 23 → 0 raw
<img>migration candidates across 18 files. Audit ratcheted: any new raw<img>fails CI unless it falls into an explicit exempt category (blob preview, og-image, local SVG, data: URI). (bc33df5 + 507fd65 + 6c0c5b5 + a7c50ba) - Bundle-size baseline + ratchet (P12.2) — 250 → 212 KB gz on the
initial bundle by dropping
Sentry.replayIntegration()(recorder was loaded even at 0% sampling; tracked as tech-debt #9 with the lazy-load recipe to re-add when needed). New audit:bundle-size fails CI at > 220 KB gz; docs/operations/bundle-size.md captures the per-chunk breakdown + what's already lazy. (a97940c) - Lazy-dep import guard — scripts/audit-lazy-deps.mjs ensures wavesurfer.js / livekit-client / @livekit/components-react are never statically imported into the client bundle paths. ALLOWED set documents the two session-only hooks that legitimately static-import (per-route code-split keeps them out of the initial bundle). (2cd8e88)
- Undoable toast pattern (P6.14) —
toast.undoable({ message, onUndo, onCommit })extends lib/toast with a deferred-commit shape. Wired into 5 destructive actions; the pattern documented inline so future destructive actions adopt it in three lines. (36806ec + 9db572c)
Audits: 36 → 38
New ratchets shipped this push:
- audit:schema-drift (P10.16)
- audit:gdpr-cascade (P10.17)
- audit:bundle-size (P12.2)
- audit:lazy-deps
Migrations to apply (Andre)
Open Supabase SQL Editor and paste:
supabase/migrations/20260527_slow_query_digest.sql— enables pg_stat_statements + adds the SECURITY DEFINER helpers the weekly digest cron uses. Thennpm run db:types+ commit the regen.
v3.0.4 — Content moderation, account lifecycle, help, hardening (2026-05-27)
A long autonomous-night push: 40+ commits adding visible product surfaces, moderation infrastructure, account-management endpoints, audit gates, and 250+ tests. Whole picture: SoundAssist now has the safety + ops scaffolding a paying-customer product needs.
What a user notices
- /help knowledge base — 18 articles across 6 categories (getting started, tracks & uploads, sharing, sessions, Connect, account & privacy). Server-rendered + indexable so product-aware searches like "how to share a folder soundassist" land directly on the relevant article. (22818b0 + 386db8f)
- Report inappropriate content — flag icon on posts (more surfaces coming). Reason picker + optional message. Reports land in /admin/moderation. 3+ distinct reporters auto-flag a target for fast-track review. CSAM is immediate, zero tolerance. (7f72c9b + 4b896bb + e4cbf95 + 19e8b3b)
- /settings/account — change your email + username from the UI. Email change is a double-confirm flow (Supabase sends link to the new address; original gets a "change requested" notice). Username change takes effect immediately (old URLs 404 — see the help article for the trade-off + workaround). (1f03097 + 676e464 + 26dfe80)
- /legal/tos — engineering-draft Terms of Service. Pending lawyer review, marked accordingly. (2e9bb55)
Behind the scenes
- Math.random room-code fix — booking + intake + public-feedback
- workspace-provisioning all generated 4-char room codes from Math.random (~13 bits of entropy). Now uses crypto.randomBytes via the shared lib/room-code.ts. 32^8 ~ 1.1T values. (7a7f1fe)
- Bulk track operations — POST /api/tracks/bulk moves or deletes up to 100 tracks in one call with per-id outcomes (not_owner / r2_failed / db_failed). Ready for the bulk-select UI to wire. (d0a5b7c)
- Content moderation backend — supabase/migrations/20260615_content_reports.sql, lib/content-reports.ts, POST /api/reports, GET+PATCH /api/admin/reports, /admin/moderation queue page, ReportButton component.
- Notification coverage audit — every ActivityType in lib/activities.ts must appear in either the in-app bell OR the email digest (or be on a justified opt-out list). Caught 7 real gaps; baseline allow-listed with REVIEW markers. (b5563bf)
- Hardcoded-URL audit ratchet — scans the source tree for
literal
app.soundassist.onlinereferences. Baseline 54; the audit fails CI when the count grows, encouraging gradual cleanup. (e971402) - Cron schedule sync audit — verifies every cron route on disk is registered in vercel.json (and vice versa) + every cron has a maxDuration entry. (ac79094)
Test coverage + tooling
- +250 tests (968 → 1218+ across the night).
- npm run ship-ready — single command, ~9s, typecheck + lint + tests + 30 audits. Pre-push verification standard.
- npm run audit:all — all 30 audit scripts in ~1.5s.
- npm run vendor:cost-snapshot — projected monthly vendor spend from local DB sums + free-tier limits.
- npm run r2:storage-estimate — same idea for R2 storage alone.
Ops + docs added
- docs/operations/secret-rotation.md — yearly cadence + per-vendor procedure for all 14 secrets.
- docs/operations/cron-heartbeats.md — BetterStack setup guide; all 8 cron routes wired to no-op heartbeats.
- docs/operations/r2-lifecycle.md — Cloudflare R2 lifecycle rules
- storage-estimate command.
Migrations to apply
Two SQL files added under supabase/migrations/; both need to be run via the Supabase SQL Editor:
- 20260610_processed_webhook_events.sql (webhook idempotency)
- 20260615_content_reports.sql (moderation queue + auto-suspend RPC)
After applying, npm run db:types regenerates lib/database.types.ts
so the temporary as any casts can be removed.
v3.0.3 — The "stop showing users our internals" marathon (2026-05-26)
A single-day push that touched 27 commits across the platform. The through-line: everywhere a non-technical user could see system internals — Postgres errors, Supabase rate-limit text, raw fetch failures, undecodable audio crashing the renderer, blind uploads with no feedback — gets replaced with plain-language UX and defence-in-depth.
What a user notices
- Storage quota in /files header — colour-coded "X GB / 5 GB" bar (grey → amber 70% → red 90%) with per-bucket hover breakdown. Auto-refreshes when an upload finishes. (188e33d)
- Upload banner became adult — size, smoothed transfer speed, ETA, and a Cancel button. Failed file in a batch no longer kills the whole queue; end-of-batch summary toast tells you what stuck. (333bfc0)
- Share-link revocation — "Rotate link" button next to Copy link on every track row (owner-only). Old URL stops working immediately; new URL copied to clipboard. For when a link leaks. (84952c8)
- Folder rename + delete + create — was missing entirely from the API; the UI buttons fell back to direct Supabase writes that bypassed every standard gate. Now full CRUD with same-origin + Bearer + rate-limit + ownership checks. Tracks in deleted folders fall back to root (not lost). (9142531 + 7d8fce9)
- Daily canary cron at 05:00 UTC hits 13 anon-reachable surfaces (auth pages, marketing, all legal pages, sitemap/robots) and fires a Sentry alert per broken surface. On-demand "Run now" from /admin/canary. Caught the first regression within hours of shipping: /legal/privacy was redirecting to /login (GDPR Art 13 violation). (105d803 + 51af8b5 + 7cde9b0 + 5f3d857)
Auth security
- Password reset hardening —
signOut({ scope: "others" })after reset so any attacker session on the old password is killed instantly. Min length bumped 8 → 12 (NIST 2017). Trivial-dictionary reject. (79b3171) - Magic-link anti-enumeration — sendMagicLink no longer surfaces raw Supabase error text (which leaked account existence via per-account rate-limit messages). PKCE callback failures now render an amber "Sign-in didn't complete" banner on /login instead of a silent fail. (3a79f81)
- Forgot-password anti-enumeration — same fix as magic-link; always show "Check your inbox" unless transport error. (79b3171)
- Sign-out helper enforcement — three pages bypassed lib/signout and left user-scoped localStorage on the device for the next visitor. All three converted; new CI gate prevents regression. (5a717b8)
Audience + copy
/legal/privacy, /legal/music-rights, and the /share/[token]
footer no longer describe SoundAssist as "for mastering engineers
and their clients". Now: "for everyone working with music — artists,
producers, engineers (mixing/mastering/recording), labels, A&R,
and the people they share music with". CLAUDE.md gets a "Doelgroep"
section so future code matches. (5f91e60)
Pipeline robustness
- Waveform renderer survives pro-DAW edge cases — Pro Tools /
Sony WAVs with JUNK/fact/LIST chunks before
data, WAVE_FORMAT_ EXTENSIBLE multi-channel, 8-bit unsigned PCM, 64-bit float, NaN-poisoned float samples, truncated-size-claim files — all parse correctly now. The SVG renderer guards against empty arrays- NaN values that would otherwise crash with NaN attribute values. 18 new unit tests on synthetic WAV buffers. (c7fea3c)
- Audio-analyze retry counter — backfill skips tracks that have failed 3 times so permanently-broken masters don't burn ~30s per attempt forever. Migration: 20260606_audio_analyze_attempts.sql. (0577d0e + 3978987)
Error-message UX (the big one)
lib/user-error.ts — central friendlyError(err, context?)
classifier. Maps any thrown value to 9 kinds (network / auth /
forbidden / not_found / validation / conflict / rate_limited /
server / unknown) with plain-language title + action sentence.
Context label tailors copy ("delete-track" → "Couldn't remove that").
Anti-enumeration is part of the contract: never echo account- existence / per-account-rate-limit text from Supabase.
Adoption: 270+ legacy toast.error(err instanceof Error ? err.message : ...) sites converted to the helper across 5 commits. Audit script
scripts/audit-user-errors.mjs flipped from informational (exit 0)
to hard CI gate (exit 1 on any new offender). New CI job
user-errors in .github/workflows/security.yml. (f1930b0 →
2cdede8)
Empty states + dashboard polish
5 high-traffic empty states converted from hand-rolled "No items"
divs to the canonical <EmptyState> component: NotificationBell,
ChatPanel, PostFeed, group TracksPanel, inbox conversation list +
thread view. Each gets icon + title + context-aware description.
(900a460)
/admin landing page gets a Canary card (Radar icon, cyan). The
existing cards (invite, email-events, email-dead-letter, costs,
email-preview, status) now have a smoke-test sibling. (5f3d857)
Migration
20260606_audio_analyze_attempts.sql — added integer NOT NULL
DEFAULT 0 column on public.tracks + partial index for the backfill
query. Non-destructive — existing rows get attempts=0 and behave
as if they've never been tried.
Audits + gates
npm run audit:user-errors— informational → hard gate (NEW)npm run audit:signout— already a hard gate- Both wired into
.github/workflows/security.yml
Late-pass cleanups (post-dinner curl sweep)
- /changelog was redirecting to /login — same regression class as the /legal/* GDPR fix from earlier today. Public marketing surface missing from proxy PUBLIC_PREFIXES. Canary's second save of the day. (ec4ac3c)
- /hire and /feedback also private — /hire is the warm-sell marketing page, /feedback is the support form including the WhatsApp emergency line. Both now anon-reachable. The canary now monitors 16 surfaces. (1f877e7)
- Mobile polish across /files and 4 admin tables — storage quota bar now visible on phones (was hidden under sm), upload banner flex-wraps so size/speed/ETA chips drop to a 2nd row instead of pushing Cancel off-screen, admin tables get overflow-x-auto + min-w sizing. (d5186cb + 05814ec)
- Login redirect preserves destination — anon users clicking /admin/canary or /workspace now end up at /login?redirect=%2Fadmin%2Fcanary instead of just /login. After signing in they land where they wanted. Was always supposed to work this way; /login form already reads ?redirect= but the proxy was dropping it. (8c36017)
v3.0.2 — Session tool Phase 2 (request/reply protocol) (2026-05-26)
Session-tool-hardening Phase 2.1 + 2.2 shipped. Connect v3.2.0 released to R2 the same day. End-to-end verified by Andre via broadcast + stop cycle in /room with Connect 3.2.0 installed.
Web side (commit 3760e68)
useStudioAudioReceiver.sendCommand(type, opts?) — new helper that
wraps sendData with request/reply correlation:
- Generates a v4 uuid
reqIdper command, attaches it to the outbound payload - Maintains a per-connection pending-requests map; resolves the returned promise when Connect echoes back a matching ack
- 1-second timeout per attempt, 3 attempts total (max 3s wall time; vs the old blind 9s scheduleRetry loop)
- Snapshot-inference fallback: if no ack arrives within timeout but the most-recent <5s session-state snapshot shows the expected post-command state, synthesise an ack and resolve. Keeps the contract working against pre-Phase-2 Connect builds.
- Pending commands rejected with "disconnected" reason on LiveKit disconnect, so callers can distinguish unreachable- Connect from ack-timeout.
/room/[roomId] migrated 4 call sites:
startStream/stopStream— optimistic UI + sendCommand in background, log on failure- Auto-recovery loop (1132-1173) — inner stream-start retries delegate to sendCommand's ack-based retry, escalates to republish after 3 cycles
- "Restart Connect" button — sendCommand with .catch logging
Connect side (v3.2.0)
useLiveKit.sendAck(reqId, ok, opts?) — emits ack back on the
LiveKit data channel after each command's FSM transition completes.
Wired into all 6 inbound command handlers (stream-start, stream-
stop, ptt-mute, ptt-unmute, republish, restart-connect).
restart-connect: emits ack first, then queueMicrotask, then
relaunch(). Replaces the v3.1.x setTimeout(..., 250) blind
delay — no more magic number waiting for the ack to flush.
session-state snapshots now include protocolVersion: 2 so web
can detect Phase 2-capable Connect builds and skip the inference
fallback.
Backward compat
- Old web (no reqId): Connect handlers skip sendAck, behave as before. Verified by running new Connect against the still-cached old web build at deploy time.
- Old Connect (no ack emit): web's pending-map times out at 1s, then falls back to snapshot inference. Verified during the ~5-minute window between PR 1 web ship and Connect 3.2.0 release.
Tests
All 24 broadcast-session.test.ts pass unchanged (FSM untouched). Web-side sendCommand unit tests deferred to a follow-up — manual smoke test by Andre in /room confirmed full flow works.
Spec
docs/architecture/session-data-channel-protocol.md captures the
design, decisions (Andre 2026-05-26), and migration order.
v3.0.1 — Security hardening + ops tooling (2026-05-26)
Defensive engineering sprint while pre-revenue. No new user-facing features; mostly invisible upgrades that catch a class of bugs or attacks before they happen.
Public /status page (P13.7)
Customer-readable uptime page at /status. Server component fetches
/api/health/env + /api/health/email and renders verdicts for 3
services (Web app + CDN, Environment variables, Email pipeline)
with a colour-coded overall pill. revalidate=60, public route,
robots index+follow - the whole point is customers can reach it when
auth is what's down. Self-built free alternative to BetterStack's
Status Pages tier (which is billable). Subscription instructions
point to info@soundassist.online manual signup until self-serve
lands. Commits 8bf93ee + 8c95c27.
Spending discipline (CLAUDE.md)
New standing rule codified: pay for nothing until paying users
exist. Self-hosted free-tier alternatives win over paid SaaS
features. Caught Resend's "Pro plan required for 2nd domain"
($20/mo) before we triggered it. Commit 8bf93ee.
Image optimisation (P12.3)
6 raw <img> tags converted to next/Image across 4 hot-path
files (/p/[username], /files, group track picker,
SessionFolderPanel). Vercel optimiser now serves AVIF/WebP variants
sized per render dimension instead of raw 4MB originals. Blob/data
URL previews in upload flows stay as raw <img> because the
optimiser can't read ephemeral URLs. Commits bf145b4 + 1506ee0.
Audio file content verification (P2.7-wire)
lib/audio-sniff.ts was lib-only since the overnight run; today
it's wired into 3 upload routes: profile-tracks/upload-complete,
f/[token]/upload-complete, r2/attachment-complete. Each route
range-reads the first 256 bytes from R2 after the upload completes
and matches the magic-byte signature against the declared MIME. On
mismatch: deleteObject + reject with 400. On R2 fail-closed:
delete + 500, ask user to retry. Catches .exe files declared as
audio/wav. Smoke-tested on prod by Andre - real WAV upload still
works, no false positives. Commit 83c3a72.
Idempotency-Key (P2.9-wire)
withIdempotency() middleware wired into 3 mutating routes:
POST /api/posts (scope posts.create), POST /api/posts/[id]/comments
(scope comments.create:<post-id>), POST /api/admin/invite
(scope admin.invite). Clients sending Idempotency-Key get the
cached 2xx response on retry within 5 min instead of double-creating
the resource. Clients NOT sending the header behave exactly as
before (opt-in, no breaking change). Commit 608ad1b.
Email rendering audit + test-send UI (P18.20)
/api/dev/email-preview got a "Send to inbox" UI: type a test
email, click "Send all 6 to this inbox", lib/email.ts sends the
templates via the real Resend pipeline so you can eyeball rendering
in actual mailbox clients. Subject prefixed with [TEST] so they
can't be confused with genuine flows. Host-gated, CSRF-protected,
separate rate-limit bucket. Used to verify all 6 auth templates
render correctly in Gmail. Commit 43f964e.
Misc
- BetterStack uptime monitoring marked done - Andre's account was already configured (P13.2).
- DMARC reports in manual-mode marked done - aggregate reports
arrive in
info@soundassist.online; automated XML ingestion deferred until volume justifies it (P18.2). /statuswhitelisted inproxy.tsPUBLIC_PREFIXES so anon visitors reach it when auth is down (same fix pattern as the/trendingbug from 2026-05-21).
v3.0.0 — Platform shift: relationships + shared folders + DJ tooling (2026-05-21)
Major version bump because the underlying model changes. Goes from "mastering engineer + clients" to "a network of relationships (clients, peers, collaborators) with shared folders for any kind of collaboration". And bolts on the DJ-DJ b2b prep loop on top of the existing LiveKit session view.
Database (5 migrations)
relationships(the follow-graph table) →follows. Frees the name for the renamed roster table. 1:1 rename, indexes + FK constraints + policies renamed alongside. Commit30d6911.clients→relationships+ newrolecolumn (client | peer | collaborator, defaultclient). FK constraints on bookings / sessions / service_orders preserve the cascade. Commit30d6911.folder_members+shared_folder_invitestables. Composite PK on (folder_id, user_id) for members; XOR check on invites (existing-user vs email-token). RLS via SECURITY DEFINERis_folder_member()to avoid policy recursion (caught the hard way mid-deploy). Commits30d6911+3a18f1d.bpm numeric(5,2),musical_key text,key_camelot texton tracks. Sharps-only key notation. CHECK constraints on all three. Commit30d6911.tracksadded to thesupabase_realtimepublication so /room SessionFolderPanel receives INSERT + UPDATE events live.
BPM + key detection — in-house DSP, MIT-licensed
Krumhansl-Schmuckler key profile (1990 paper, public domain) + onset envelope autocorrelation with fractional-lag interpolation for BPM. fft.js for the FFT, no AGPL/GPL libraries. 23 unit tests on synthetic Gaussian-pulse signals (90, 120, 128, 175 BPM all within ±2 BPM, all 24 keys round-trip cleanly).
Runs in lib/audio-analyze.ts::analyzeFromR2Key(), hooked into
every audio upload path:
/api/profile-tracks/upload-complete— My Tracks/api/r2/attachment-complete— My Files + Connect drag-drop/api/f/[token]/upload-complete— public shared folder/api/upload/[token]— public-feedback link
Plus admin-gated /api/admin/backfill-audio-analyze for the
historical catalog. Commits fc53ac4 + 4d2c0d8 (analyzer
everywhere bundled with email-suppression work).
Shared folder backend + API
POST /api/folders/[id]/members— owner adds a member by user id (instant-add)DELETE /api/folders/[id]/members/[userId]— owner removes someone OR member leaves themselvesPOST /api/folders/[id]/invites— email-token invite, sends email via Resend with the inviter + folder nameDELETE /api/folders/[id]/invites/[inviteId]— cancel pendingGET /api/invites/[token]— public preview (inviter + folder)POST /api/invites/[token]/accept— authed accept, links user to folder_members, fires activity stream event
Activity stream extended with shared_folder_member_added,
shared_folder_member_removed, shared_folder_invite_received.
FeedDropdown renders them. Commits 30d6911 + 0d844e3 + f6bc683.
Web UI
/clientspage renamed in copy to "My Relationships" + role tabs (All / Clients / Peers / Collaborators). Each card has a click-to-cycle role badge (CLIENT green / PEER purple / COLLAB amber). Commitsc305b01+dd073b7+f417fd7.- Dashboard tracks rows +
/listen/<token>+/p/<username>all show a purple BPM·key·Camelot pill ('140 · Am · 8A') when the analyzer has run. Tooltip carries the full info. Commits4e473c8+65e079f+1a78e8f. - Click-to-filter on dashboard pill: GitMerge icon next to the pill
filters the list to harmonically + tempo-compatible tracks
(±1 / mode-swap / +7 Camelot, ±6% BPM). Commit
3011ba5. /filesaudio rows show the same BPM/key pill (3f19196)./filesfolders: purple dot for shared-with-me + member-count chip for folders I own + 'Leave' action on shared folders (f6008f3+5a18d38+f209331).- Share-folder modal (
components/files/ShareFolderModal.tsx) with email invite, copy-link, member list, remove member. Commit0d844e3. /invites/[token]public landing page — preview card with inviter avatar + folder name + Accept / Sign-up / Log-in CTAs. Commit0d844e3.
DJ-DJ b2b prep — SessionFolderPanel (Phase 06)
Inside /room/<roomId> a "Setlist" toggle opens a right-rail panel:
- Pick a folder (eigen or gedeelde)
- Track list with BPM · key · Camelot pill per row
- Drag-drop audio onto the panel → upload + analyzer fires server-side
- Realtime INSERT + UPDATE so DJ B sees DJ A's uploads within ~200ms + the BPM pill appears live ~15s later
- In-session preview cue (no new-tab disruption of LiveKit audio)
- Member-presence avatars in the header (who else is in the same folder right now)
- "Use this" mark per track, shared between members via Realtime broadcast — DJ A taps, DJ B's row highlights instantly
All ephemeral collaboration state via one supabase.channel per
folder — no DB schema for marks/presence. Commits cea0944 +
9040720 + 0a22777 + df6aea8.
Hygiene
- Pre-commit hook now runs
biome check .before typecheck + tests, so lint-fail-on-CI is impossible. Commit2af01ad. - Proxy whitelist for
/invites/+/api/invites/(caught by smoke-test). Commit1e1414d.
Unreleased — Platform maturity + social differentiators (2026-05-20)
Two-day sprint focused on turning a feature-rich MVP into a platform that doesn't fall over. 30+ commits across performance, security, code hygiene, and four out-of-the-box features that make the shared-link experience match the engineering quality of the codebase. Net result: 31 of 45 roadmap items shipped, productive gates green, build pipeline + schema-drift guard + structured logging all wired up.
Public user-facing features
- Posts edit + delete for both posts and comments. Inline
textarea + Save/Cancel, server PATCH author-only. Commit
a61939c. - Track reposts: any signed-in viewer can repost a track from
a group or another profile onto their own profile feed. Pure
share button, optional commentary. Schema column
posts.reposted_track_idenforced via XOR CHECK withreposted_from_post_id. Commit7284098. - Collaborator credits with @-mentions. Track upload forms
for the three credit roles (engineer / producer / label) gained
autocomplete: type
@andrand pick from matching profiles. Both the display text and the resolveduser_idare stored; display layer (CreditLine) renders @handles as clickable links. New "Credited" tab on every profile lists tracks where this user was tagged by anyone. Engineers + labels now have a geverified portfolio that builds itself. Commitf6b3723. - Dynamic Open Graph + Twitter card images for
/listenand/p. Every shared link now renders a proper 1200×630 hero card (artwork + title + artist for tracks; banner + avatar + role for profiles) instead of a grey placeholder. Built via Next.jsopengraph-image.tsx/twitter-image.tsxconventions, served at request time, edge-cached. Commits5f57a60,a45f62c. /trendingdiscovery page. Top 20 tracks of the past 7 days ranked by weighted score (likes + 3×comments + 5×reposts). Hero card for #1, dense list for 2-20. Anonymous-friendly so shared links work as landing pages. Dashboard widget now links to the full ranking. Commitffe41ee.- Embed widget at
/embed/<token>+ oEmbed endpoint at/api/oembed. Tracks pasted into WordPress / Medium / Notion / Discord auto-expand to an inline 180px-tall player. Each embed carries a SoundAssist wordmark + "Open" link as gratis reklame on every paste. Per-route CSP override allows the iframe to render anywhere. Commits75e60a0,a45f62c.
Performance
- Bearer-token cache: 60s in-memory SHA-256 cache on
getUserId(). 30-100ms saved per repeat call. Commit00fa7ce. - Post-feed query bundling: GET /api/posts collapsed three
separate aggregation queries (likes / comments / reposts) into
one
get_posts_with_stats()RPC using LATERAL subqueries. 4 round-trips → 2. Commit4154cfa. - Edge caching with
s-maxage+stale-while-revalidateon/api/profiles/[username],/api/listen/[token],/api/posts,/api/groups/[slug]/tracks. New helpers inlib/cache-headers.tskeep the policy uniform; viewer-aware variant returnsprivate, no-storewhen a Bearer token is present so per-user state never lands in a shared cache. Commitb0f1f84. - Bulk LikeButton fetch: a profile page with N tracks no
longer fires N parallel
GET /api/track-likes/[token]calls. NewTrackLikesProviderwraps the list, fires one bulk call via/api/track-likes/bulk, hands the results to each LikeButton via context. Commitd706b04. - AdaptiveImage via Next/Image with explicit
remotePatternsfor our CDN. AVIF/WebP serving, srcset, lazy-load default,priorityopt-in on hero banners. 4MB banner → ~80KB AVIF on mobile. Commit9f96a25. - Listen page seeded from the listen payload so LikeButton
doesn't fire its own GET. Plus the listen fetch now passes
the Bearer header so
viewer_likedis correct for authed users. 3 network trips → 1. Commit8c64839. - BRIN index on
activities.created_atfor the append-only event stream. Commit6dc66d1. - Bundle audit:
optimizePackageImportsforlucide-react@supabase/*so barrel-named imports get rewritten to deep paths at compile time. Commit4c808d3.
Security
- Rate limiting on every social write endpoint via the
existing
lib/rate-limithelper. Per-user buckets, 60s windows. Posts 10/min, comments 30/min, likes 60/min, reposts 10/min. Commitcad261c. - Magic-byte validation on server-side image uploads. New
sniffImageMime()/verifyImageBytes()inlib/r2.tsreads the first 12 bytes and rejects anything where the declared Content-Type doesn't match. Wired into the 4 multipart upload routes. Commitf08fcaa. - CSP tightening (then reverted): tried an explicit allowlist
for img/media/connect-src; broke audio playback because of a
hostname mismatch. Reverted in
a457830. Re-do viaContent-Security-Policy-Report-Onlyis tracked as a follow-up. - XSS audit: zero
dangerouslySetInnerHTMLin the codebase, zero.innerHTMLassignments in app code. Confirmed via grep- Biome lint config. Commit
2d5213f.
- Biome lint config. Commit
- Secret-leak defense: new
redactForLog()helper inlib/redact.tsscrubs known secrets (SUPABASE_SERVICE_ROLE_KEY, STRIPE_*, R2_*, OPENAI_API_KEY, etc) from anything passed through it. Belt-and-suspenders for the existing console.error audit. Commitcbb5dd6. requireSameOriginaudit: applied uniformly to private user-mutating routes (profile, profiles/[username] PATCH, messages, clients, workspace, stripe portal, send-session-email). Belt-and-suspenders since Bearer auth already prevents CSRF. Commit485779b.- npm audit fix: ws 8.20.0 → 8.20.1 (uninitialized memory
disclosure, GHSA-58qx-3vcg-4xpx). Commit
93e76ed. - Admin audit log for moderation actions. New
admin_audittable +recordAdminAction()helper wired into the group member-mutation routes (kick, ban, role-change, ownership transfer). Internal forensic trail; service-role only, client-invisible. Commit799e833.
Code & architecture
- withAuth() route wrapper in
lib/api-helpers.tsbundlesrequireSameOrigin+requireUserId+ optional rate-limit into one composable. Demonstrated on the post-like route. Future routes default to this. Commitda4f789. - knip dead-code sweep: removed 250 lines of dead code
including 7 legacy
client_fileshelpers, anisUuidShapere-export, and three unused types.npx knipnow exits 0 cleanly. Commitf83f9b9. - Schema-drift guard at pre-commit:
scripts/check-db-types.mjsdiffs the generatedlib/database.types.tsagainst the live Supabase schema; husky runs it whenever a commit touches a migration or the types file. Bypass viaSKIP_DB_TYPES_CHECK=1. Commit7f299ab. - Shared response types in
types/api/posts.ts. Both server routes and client components now import the samePost,PostAuthor,RepostedFrom,RepostedTrack,PostCommentdefinitions. No more silent drift. Commite481115. - Uniform error response helpers in
lib/api-errors.ts:apiError,unauthorized,forbidden,notFound,invalidInput,conflict,unsupportedMedia,rateLimited,serverError. Every route returns the same{ error, message?, code? }shape. Commit76e6465. PostFeed.tsxsplit from 984 lines into 7 focused files undercomponents/posts/. Same behaviour, much shorter per file. Commitbd6b84b.- Structured logger at
lib/log.ts. JSON output to stdout/stderr with secret redaction baked in. Opt-in for new code; oldconsole.errorcalls stay unchanged. Commit807a12b.
Developer experience
npm run checkcombo runs typecheck + tests + lint + knip in one command. Commit1e33f84.- Routes index at
docs/api.md, auto-generated bynpm run docs:routesfrom theapp/api/**/route.tstree. 86 routes catalogued. Commits318c5c6,aff1ff7. - ARCHITECTURE.md brought current with the social pivot:
data model, polymorphic-owner pattern, share_token model,
two-tier group membership, RLS-recursion fix, activity stream,
R2 layout, encoding pipeline, glossary. Commit
3eff2a6. - CLAUDE.md project patterns section: how to write a new
route, error helpers, RLS gating, migration conventions,
test patterns, image uploads, and (post-mortem) Next.js
segment-config files. Commits
f168771,aff1ff7.
Internals
Migrations applied (Supabase production):
20260520_posts.sql- polymorphic posts table20260520_post_interactions.sql- likes, comments, reposts20260520_track_reposts.sql-posts.reposted_track_id20260520_posts_stats_function.sql- aggregation RPC20260520_activities_brin_index.sql- BRIN on created_at20260520_admin_audit.sql- moderation forensic log20260520_credit_user_ids.sql- resolved credit FKs
104 unit tests pass (up from 53). New suites:
api-auth,api-helpers,api-errors,cache-headers,log,r2-sniff,redact.
Known follow-ups (parked, not lost)
- CSP redo via
Content-Security-Policy-Report-Onlyto gather real-world violation telemetry before re-enforcing the strict allowlist - Account lockout (needs Upstash KV or similar shared store)
- RLS test matrix with two test accounts
/tracks/newstate-machine refactor (1000+ line file)- E2E Playwright suite (needs Stripe test mode + seed accounts)
Unreleased — Design polish + mini-player + Groups roadmap (2026-05-19)
15 commits in a single working day, almost all about pulling the public profile + dashboard out of "feels like an MVP" into "feels like a real product". Net result: brand reads as Bandcamp-for- mastering-engineers on every viewport, persistent audio player across the profile page, plan for Groups + Livefeed locked in.
Design / visual identity
- Tall artwork tile on every track row — public profile + dashboard
both get a 96px → 140px (mobile → desktop) square cover-art tile
spanning the full row height instead of a 40x40 thumbnail next to
the play button. Clicking opens a lightbox at up to 90vw/90vh;
empty tiles trigger the upload picker on the dashboard. Spotify /
Apple Music album-art pattern. Commits:
c9f01db,e190048. - Square avatar tile on the public profile banner — same pattern
as the track artwork. Desktop: 160-180px vierkant left edge. Mobile:
16:9 banner across the top (down from 1:1 full-viewport hero which
was eating the whole above-the-fold). Click opens existing
AvatarLightbox. Commits:
ca2bbbb,3605536. - 2-row track headers — title was getting truncated on every
viewport by the inline play-button + 6 badges + actions sharing
one flex row. Restructured to title-on-its-own-row (line-clamp-2)
- actions-on-row-2. Studio Master badge (24-bit / 44.1 kHz) sits
next to title on desktop, hops to row 2 on mobile so title gets
full right-column width. Commit:
d7caa88, mobile fix in3605536.
- actions-on-row-2. Studio Master badge (24-bit / 44.1 kHz) sits
next to title on desktop, hops to row 2 on mobile so title gets
full right-column width. Commit:
- Profile widened to max-w-4xl (896px) from max-w-2xl (672px).
On a 1920px monitor the page was reading as a narrow column
floating in a dark void. Deliberately narrower than SoundCloud /
Spotify (~1200px) — "intimate reading width" reinforces the
lossless / mastering positioning. Commit:
a673f60. - Responsive padding + touch targets —
px-4 sm:px-6 lg:px-8ladder on both profile and dashboard. Play button h-10 on mobile (close to Apple HIG 44px) shrinking to h-8 on sm+ for mouse density. Commit:fc5cc13.
Audio playback
- Persistent bottom mini-player on the public profile — Spotify-
style strip with artwork + title + prev / play-pause / next +
click-to-seek progress bar. Stays visible while scrolling. Reads
state from the active TrackRow's WaveSurfer instance via a ref-map
- a small set of new callbacks; deliberately not a global audio
context refactor. Cross-route persistence (so audio keeps playing
when navigating to /listen or /tracks/new) is a fase-2 item.
Commit:
a630592.
- a small set of new callbacks; deliberately not a global audio
context refactor. Cross-route persistence (so audio keeps playing
when navigating to /listen or /tracks/new) is a fase-2 item.
Commit:
Navigation
- Livefeed dropdown next to the bell in both desktop nav and
profile owner nav, plus mobile dashboard inline bar. Companion to
notifications: ambient stream of uploads from people I follow
(different mental model than direct-mention notifications). The
backend (
/api/activities/feed, activities table,recordActivity()) was already in place from earlier work — this just exposed it as a top-nav surface. Commits:a614dcf,8af1dbe. - NotificationBell mounted on the profile owner nav — bell + dropdown +
polling all worked from the dashboard nav since earlier commits,
was just never wired into the profile page. Added a
variantprop to the component so icon-mode matches the round buttons there. Commit:b0e0ff3. - Followers / following counters clickable at 0 —
FollowListModalalready had an empty-state ("No followers yet"); the trigger button was disabled at 0 so brand-new accounts couldn't even see the modal. Now only disabled while counts are loading. Commit:3e8ace2.
Brand positioning
- Sessions widget gated behind internal-user flag — "Create or
join session" + "Got a room code?" + "Stream audio from your DAW"
blocks now only render for the founding team (hardcoded email
allowlist in
lib/internal-users.ts). Reason: the macOS Connect build doesn't exist yet, and a Windows-only download CTA on a Bandcamp-style platform reads as use-case confusion. Routes (/room/[id],/download) still work; the pairing flow is untouched. Commit:49bd944. - Upload button routes to
/tracks/new— the public profile Upload button was opening a legacy inline file picker (ProfileTrackUpload component), which skipped the title / genre / description / artwork editing step that the dedicated upload page introduces. Tracks ended up in My Tracks with no metadata, silently. One door into upload now:/tracks/new. Deleted the ProfileTrackUpload component (374 lines of dead code). Commit:accf0ac.
Mobile fixes
- Avatar hero on mobile was 1:1 full-viewport (~360x360) → all
content under the fold. Now
aspect-video(16:9 ~200px tall) on mobile,aspect-square160-180px on sm+. Commit:3605536. - Studio Master badge demoted on mobile — was crushing the title
in row 1, now appears in row 2 next to the play button. Title gets
full ~240px right-column width on 360px viewports. Commit:
3605536. - Feed + Bell icons added to mobile inline nav — only Inbox was
there before, no way to reach feed/notifications without opening
the drawer. Commit:
8af1dbe.
Documentation
- GROUPS-ROADMAP.html — standalone visual roadmap doc for the
Groups + Livefeed multi-week project. 10 anchor decisions locked
(no auto-include, multi-admin promotable, separate groups table,
three join policies, etc.), 7 phases sketched with acceptance
criteria, schema preview embedded. Dark theme matched to brand;
status badges (Planned / Doing / Done) updated as fases complete.
Commit:
9b41fc5.
Brand
- New logo with "Your sound. We assist." slogan — Andre redesigned
the mark as a single composition (wordmark + black slogan block).
SVG primary asset (39 KB, scales without quality loss), generated
PNG fallback (24 KB, 1024x768) for emails / OG cards / Apple touch
icon.
filter: invert(1)works identically on both formats so the dark-website inversion stays unchanged. Logo component size map updated from 2.3:1 to 4:3 ratio. Commit:1297dc8.
Cleanup
- Removed dead Opus 510 encode pipeline —
encodeOpus510(),encodeLocalToOpusAndUpload(),EncodeMetainterface,opus510Key()helper. All unused since the FLAC streaming switch (2026-05-18). Read-side fallback for legacy tracks withopus_r2_keystays in place. Knip post-cleanup: 0 unused exports. Commit:d98a800.
Deferred to next sessions
- Groups foundation (fase 1 of the new roadmap) — schema migration, /groups/new form, /g/[slug] page, first join-request flow.
- Container queries on TrackRow so it reflows by container width rather than viewport breakpoints (needed when the row gets embedded in narrower sidebars / modals).
- Mini-player fase 2: global audio context, shuffle / repeat / volume, cross-route audio persistence.
Unreleased — Principal Engineer roadmap, Phase 1-5 (2026-05-18)
Marathon hardening pass through phases 1 to 5 of the master roadmap. 78 of 124 items resolved in a single session, plus a silently broken Vercel-GitHub webhook on the marketing site discovered and fixed (4 commits had been stranded on origin without deploying for ~24h).
Security
- 2FA enforced on all six operational platform accounts: GitHub, Vercel, Supabase, Stripe, Cloudflare, Resend. Recovery codes stored locally. Google Authenticator backed up to Google account cloud sync.
- Rate limit on
/api/transcribe(Whisper costs real money). 20 calls/user/hour + 40 calls/IP/hour. Combined with the OpenAI org-level $25/month budget cap, max realistic exposure for a compromised account is now ~3-4h before the org cap kicks in. /api/public-feedback+/api/upload/[token]whitelisted in proxy.ts. Both routes are documented as anonymous landing-page endpoints, but neither was inPUBLIC_API_PREFIXES. The proxy returned 401 to cross-origin POSTs from soundassist.online, silently breaking the "Get free feedback" CTA in production. Found during the Phase 3.12 functional check.SECURITY.mdwritten. Per-secret rotation playbook for all 9 credential classes. Vulnerability reporting flow. Defence-in- depth notes. Last-reviewed date so reviewers can spot staleness.
Added
- Typed Supabase Database end-to-end.
lib/database.types.tsgenerated viasupabase gen types typescript. All clients now parameterised with<Database>. Surfaced 7 latent bugs on first typecheck (nullable date fallbacks; narrowed update payloads; runtime Array.isArray guard forwaveform_peaks; explicitJsoncast in activities). Newdb:typesnpm script regenerates after every migration. - Sentry error tracking wired via the official wizard with
GDPR/quota-friendly defaults: tracesSampleRate 0.1, sendDefaultPii
false, replays only on error, env-var DSN so the SDK is a no-op
without configuration. Tunnel route at
/monitoringbypasses ad-blockers. Verified end-to-end. - Per-user subscription + profile cache in
proxy.ts. 30-second TTL Map keyed by userId, eliminates 1-2 Supabase round-trips per authenticated page nav. Stripe webhook invalidates on every subscription mutation. 7 new tests inlib/proxy-cache.test.ts. - Mobile hamburger nav on the host dashboard. Desktop layout overflowed on phones — "Sign out" wasn't even visible. Slide-in drawer from the right (80% width, 48px tap targets, body scroll-lock when open). Profile card stacks differently per breakpoint to stop role/country chips colliding with edit button.
- prefers-reduced-motion respected globally via
globals.css. - apple-touch-icon link so iOS "Add to Home Screen" produces a real icon instead of a screenshot.
- viewport-fit cover for edge-to-edge rendering on notched iPhones. Combined with the dvh refactor (63 sites in 30 files), no more 100vh URL-bar jitter.
- Vercel Analytics on the marketing site via
<script defer>in all 14 live HTML pages (the static site can't use the npm package). App side switched to@vercel/analytics/next. - WebP variants on the marketing
/studioand/platformpages via<picture>+<source>with JPG/PNG fallback. 190 KB saved per visitor. - Privacy Policy at /privacy.html — GDPR-aligned, all 10 sub-processors listed, legal basis per purpose, retention table, Art. 15-22 rights enumerated.
- Terms of Service at /terms.html — Dutch consumer law-aligned. Refund policy (Art. 16(c) CRD), IP rights, subscription cancel, liability cap, Dutch jurisdiction, EU ODR pointer.
RUNBOOK.md— 9 incident playbooks (site down, DB down, R2 down, Stripe webhook fail, email fail, leaked secret, data loss, mass spam, plus one-liners). Optimised for 3am readability.CONTRIBUTING.md— first-time setup, command reference, codebase tour, conventions, PR process.docs/R2_BACKUP.md— three-tier backup strategy with the "stay at Tier 1 until €1k MRR or 50 GB stored" trigger + rclone recipe..env.example— all 20 env vars in use, grouped by service.- Lint step in GitHub Actions CI (
npm run lint). CI gate for anyone who bypassed the pre-commit hook.
Fixed
- Landing CTA broken in production (proxy whitelist — see Security above).
- Vercel-GitHub webhook silently disconnected on marketing repo. 3 commits stranded for ~24h. Settings → Git → reconnect restored auto-deploys; backlog drained on first push afterwards.
min-h-screen→min-h-dvhacross 63 occurrences (30 files). iOS Safari's 100vh counted the URL bar; dvh resizes smoothly.
Performance
- TTFB baseline + post-cache measured. Hot path on dashboard saves 30-100 ms by skipping Supabase round-trip.
- Cloudflare Cache Rule "Cache R2 audio aggressively" on cdn.soundassist.online. Edge + browser TTL 1 year.
Monitoring
- BetterStack uptime monitors on
/api/health+ marketing home. 3-min interval, email alerts. - Stripe failed-payment notifications verified on.
- Vercel deployment failure notifications verified on.
- Vercel rollback drill completed. Instant Rollback works in ~10 seconds.
Infrastructure
- OpenAI usage cap at $25/month with 80% + 100% email alerts.
- Vercel Sentry integration sets SENTRY_AUTH_TOKEN/ORG/PROJECT at build time so source maps upload during deploy.
- Supabase 8-day rolling daily backups confirmed. PITR add-on explicitly skipped — not justifiable below €5k MRR.
- Supabase connection pooling verified: Transaction mode, pool size 15, max 200 clients.
.gitignorenoise removed (supabase/.temp/, profile.png, release-concept.html). Knip clean of the supabase ignoreDependencies hint.
Unreleased — community-flywheel hardening pass (2026-05-15)
Cross-model diagnostic + security audit followup. Four parallel review agents (security, code review, DB/RLS, frontend) ran against the Phase 3-6 diff. Cross-confirmed findings fixed. No CRITICAL items. Most-impactful fixes: identity binding on comments, RLS tightening on track_likes, CSRF + rate limits on relationships + likes.
Security
- Comments: server overwrites author_name + author_role from the Bearer-resolved profile. The client-side nameLocked guard was bypassable via curl — anyone with a Bearer could post as anyone else.
- Likes: self-likes no longer record an activity event (confusing "Andre liked Andre's track" rendering). 30/min per-user rate limit.
- Follows:
/api/relationshipsPOST/DELETE now require same-origin (other community endpoints already did) + 20/min per-user rate limit. Explicit JSON parse try/catch (was raising 500 on malformed bodies). - Comment threading:
parent_idstrict validation. Must be a UUID, must reference a comment on the SAME track, must NOT itself be a reply (one-level cap now enforced server-side too). - CSRF whitelist: dropped the wildcard
*.vercel.apptrust path. attacker-csrf.vercel.app was previously accepted as same-origin. Whitelisted onlyprocess.env.VERCEL_URLfor the actual deployment. track_likesRLS: tightened fromUSING (true)to "you OR the track is public/voice-message". The wide-open policy let anon clients dump the like graph for Unlisted tracks. Migration is idempotent — re-run20260515_track_likes.sqlin Supabase to apply./api/profile-activitygates on the target profile'sis_public. Anyone with a UUID could previously enumerate a private user's uploads/likes/comments.- Error sanitisation: notifications + relationships routes no longer echo raw Postgres errors back to the client (leaked column names, constraint names, RLS policy names). Generic strings client-side, full error server-side via console.error.
getUserIdnow matches the Bearer scheme case-insensitively per RFC 7235.
Fixed
- Dashboard encode polling: fresh WAV uploads stayed at "Encoding"
badge forever until hard refresh.
/p/[username]had the 4s poll; the dashboard now has it too. Andre hit this during the Phase 3-6 session. - NotificationBell: each row is a real
<button>, not a<div onClick>. Keyboard Tab + Enter/Space now mark-read. - LikeButton race: synchronous
busyRefblocks rapid double-clicks from racing the optimistic flip.mountedRefskips setState after unmount. - LikeButton anon click: no longer silently no-ops. Surfaces "Sign in to like this track" toast.
- TrendingTracks null host: deleted host profiles no longer render
broken
/p/links. Avatar + name fall through to plain text. - ActivityFeed / NotificationBell empty gaps: rows with null actor are filtered before mapping (rare cascade-delete race window).
- Activity feed truncation: over-fetch + filter so recent events targeting Unlisted tracks don't shrink the visible feed below 30.
Changed
lib/relative-time.tsextracts the relative-time formatter that was inlined in three components with a hard-coded Dutch "u" suffix. Suffix is now "h" (English) so non-Dutch viewers can read it.
Unreleased — community flywheel (2026-05-15)
Peer-to-peer community features land on the dashboard, public profile, and listen pages. Artists can now find each other, follow each other, hear each other's work, and engage. No editorial curation — the whole thing is data-driven so it scales without an Andre-bottleneck.
Added
- Activity feed on the dashboard — events from people you follow
- your own, newest first. Powered by a single
activitiestable (jsonb metadata) with two read queries: feed (actor in followed-set) and notifications (target_user_id = me).
- your own, newest first. Powered by a single
- Notification bell in the top nav with unread badge. Polls every 60s while the tab is visible, pauses while hidden. Mark-all-read and per-item mark-read both supported. Notifications for: new followers, comments on your tracks, likes on your tracks.
- Likes — heart button on
/listen/<token>and inline on every/p/<username>track row. Backed by atrack_likes(user_id, track_id) composite-PK table so a user can't double-like. - Comment threading — Reply button on every comment, one level deep (matches SoundCloud, avoids Reddit-infinity-indent on mobile). Replies inherit the parent's timestamp.
- Signed-in commenter identity — auto-filled author name pulled from the session; comments from logged-in viewers now flow into the activity stream + notify the track owner (were anonymous before).
- Profile "Recent activity" section on
/p/<username>showing uploads, likes, and comments by that artist. Self-hides on empty. - Trending tracks dashboard widget — 7-day window, score = likes + comments, tiebreak newer-first. Ranks by engagement, not editorial.
- Discover artists widget on the dashboard (already shipped in Phase 2, mentioned here for the changelog reader's mental model).
- "Follows you" badge + "Follow back" button on profiles where the viewed user already follows the viewer. Reciprocal-edge nudge.
- Clickable track titles on
/p/<username>→/listen/<token>page. Visitors can engage with a track (comment, like, scrub the waveform) without leaving the profile by hand-typing a URL.
Changed
- Listed / Unlisted replaces Public / Private on track rows. The share_token has always been an unguessable UUID, so the link is the credential — "Unlisted" controls profile-page visibility, not link reachability. An artist sending a fresh mix to one person can now flip the track to Unlisted and the link still works. Matches the YouTube / SoundCloud mental model.
/api/listen/[token]drops theis_publicprivacy gate. Anyone with the share_token gets in; owner-only paths (download forallow_download=falsetracks) are unchanged./api/relationshipsaccepts both{ followed_id }(legacy) and{ targetId }(DiscoverArtists + FollowListModal sent the latter, server only honored the former). Recording a 'follow' activity is gated to genuinely-new follows so re-toggling doesn't spam the feed.- Copy link button on the dashboard now renders for both Listed AND Unlisted tracks.
Fixed
/api/relationshipsPOST/DELETE silently ignoredtargetId— the DiscoverArtists Follow button and the FollowListModal Follow toggle both senttargetIdwhile the server only readfollowed_id. Symptom: clicking Follow in those surfaces appeared to do nothing.
Infrastructure
- Three new Supabase migrations (
20260515_activities.sql,20260515_activity_read_state.sql,20260515_track_likes.sql) with RLS policies + partial indexes for the hot-path queries (unread notifications, likes per track, activity per actor). - New
lib/activities.tshelper centralisesrecordActivity()calls. Errors are logged and swallowed — a broken feed insert never tanks the underlying follow/upload/comment/like action.
Unreleased — beta-readiness sprint (2026-05-06)
Critical fixes (cross-flow audit)
A platform-wide audit surfaced that the wrong-bucket bug we fixed in the track DELETE flow was actually present in 4 more places. Each silently broke a real feature:
/api/f/[token]GET — public folder downloads returned null URLs/api/f/[token]/upload-url— public folder uploads went to a non-existent bucket/api/messages/[userId]— inbox attachments + voice messages had no playable URL/api/profiles/[username]/tracks/[trackId]— public profile track playback returned null signedUrl
All four switched to the correct session-files bucket. Plus
/api/f/[token]/upload-url now uses the canonical tracks/...
prefix so files uploaded into a host's shared folders pass
/api/download's ownership guard.
Reliability
app/room/[roomId]/page.tsxhandleLeave now wrapsupdateSessionin try/catch. If the DB update fails after a successful recording upload, the orphan.webmis removed via the new/api/recordings/cleanupendpoint (same shared-secret model as/api/end-session, refuses paths outsiderecordings/prefix or that don't match the canonical session form)./api/f/[token]/upload-completenow removes the storage object on either failure path (link expired between upload-url and complete, or DB insert fails). Mirrors the cleanup we did for/api/upload/[token]in inbox1./api/folders/[id]/sharere-selects the canonical share_token after the write. Prevents two near-simultaneous share clicks from each returning a different token while only one wins in the DB.
Polish
middleware.ts→proxy.ts(Next 16 deprecated the middleware filename; same handler, new nameproxy). Build no longer emits the deprecation warning.- Feedback link in the host-dashboard nav.
mailto:toinfo@soundassist.onlinewith a pre-filled body (browser / device prompt + signed-in user's email). Hardcoded — one-person team does not need a separate feedback inbox, and a forgotten env var would silently break the link.
Known issues (deferred)
These were surfaced by the cross-flow audit but deliberately not fixed in this sprint:
- C5: Track upload orphan window when the browser closes
between the storage PUT and
createTrack. Slow leak; the storage-audit script catches it. A proper server-side/api/tracks/upload-completeendpoint would close the gap. - M1:
tracks.is_public = falsedoes not invalidate the existingshare_token. The/listen/[token]URL keeps working forever. Probably intentional (share links are permanent-by-design) but the asymmetry will surprise some users. UX decision pending. - M2: Deleting a client cascade-deletes its sessions but
the
recording_pathfiles in storage and anyclient_files.storage_pathblobs are NOT removed. Build a Postgres trigger or a server-sidedeleteClientthat walks both before deleting the row. - M3:
client_files,upload_links,client_file_foldersexist in code but not insupabase/migrations/. Cannot verify their FK + cascade behaviour. Add migration files next time someone touches the schema. - M5: Double-delete in two browser tabs returns 404 on the second one — verify the My Tracks UI swallows that quietly rather than throwing a scary toast.
- M6: When a track is deleted, messages whose
attachment_track_idpointed at it lose their attachment preview text. The thread view should fall back to "Attachment removed" instead of an empty bubble.
Hygiene + tooling
.editorconfig+.gitattributesenforce UTF-8-no-BOM and LF line endings across all source files. Stops the BOM-regression cycle that bitapp/page.tsxrepeatedly during the inbox1 audit.- Husky
pre-commithook runs typecheck + tests before every commit. Bypassable with--no-verifyfor emergencies. app/api/healthendpoint for liveness + dependency checks. Public, returns 200/503 with per-dependency status JSON.- Branded
app/not-found.tsxandapp/error.tsxreplace the default Next.js error pages. app/robots.ts+app/sitemap.ts: indexable for landing, pricing, download, login, public profiles. Disallow API, authenticated, and any token-bearing URLs.
inbox1 — 2026-05-05
The full inbox + network release. From this point forward SoundAssist has DMs with attachments + voice, follow/unfollow, verified credits, and a hardened security surface.
Added
- Inbox attachments (be006e8): drag a track from My Tracks / My Files into a DM. Inline audio player on receive.
- Voice messages (be006e8): in-composer recorder. Voice
msgs are tracks with
is_voice_message=trueso they don't pollute My Tracks. Compact playback widget in the thread. - Verified credits (6ab18da): Mixed by / Mastered by / Produced by, with mutual confirmation. Either party can claim; only the counterparty can confirm/decline.
- Pending-credits badge in nav (0556ce6): polls every 30s.
- Read receipts in inbox: ✓ sent, ✓✓ read.
ARCHITECTURE.md(fbda494): single-source map of the system — data model, auth, real-time pipes, security posture.README.md: quick start, conventions, deploy flow.- GitHub Actions CI: typecheck + vitest on push and PR.
scripts/storage-audit.mjs+scripts/storage-cleanup.mjs: read-only audit and (opt-in) destructive cleanup for orphan storage objects.
Changed
/api/messages: now acceptsattachment_track_id. Conversation preview and thread API hydrate attachments with 1h presigned URLs.- Migrations consolidated under
supabase/migrations/:20260101_initial_rooms.sql,20260102_clients_sessions.sql,20260103_session_files.sql(renamed from root-level files). lib/file-utils.ts(split from inline copies in 4 places):AUDIO_EXTS,fileExt,isAudioPath,fmtBytes,fmtDate.FileTypeIconlives incomponents/so the lib stays JSX-free and testable.lib/rate-limit.ts: in-memory sliding-window limiter shared across public upload routes.
Fixed
- Track delete bucket bug (3267cf1):
/api/tracks/[id]DELETE usedstorage.from("tracks")(a non-existent bucket) so storage files were never removed. The silent.catchmasked the error. One user accumulated 62 orphan WAVs. Fixed + cleanup script run. - Inbox redirect to /login (f149d4c): inbox page constructed
its own raw supabase-js client (localStorage) while the rest of
the app uses
@supabase/ssr/createBrowserClient(cookies). Switched inbox to the sharedlib/supabaseclient. - Rate-limit memory leak (CRITICAL) (9bd5b81): the buckets Map grew unboundedly because expired buckets only got cleaned when their own key was touched. Could be exploited to OOM the Vercel function. Fixed with a periodic sweep + 50_000 hard cap.
Security
/api/tracks/upload-url(648ead1): now requires Bearer auth and validatesuserId === auth.uid(). Rejects executable.html+.svgextensions (9bd5b81).
/api/download(648ead1): requires Bearer auth and verifies the caller owns the storage path viatracks/session_files/client_files. Rejects path-traversal (9bd5b81)./api/tracks/[id]/stats(648ead1): only increments for publicly-accessible tracks. 5/min/IP rate limit./api/upload-file-url(648ead1): validates room exists- isn't expired before signing upload URL. 10/min/IP rate limit.
/api/relationships(2713d88): hides profiles whereis_public = falsefrom follow lists./api/folders/[id]/share(2713d88): Origin / Referer guard against CSRF, anchored onNEXT_PUBLIC_APP_URL(not the attacker-controllableHostheader) (9bd5b81)./api/f/[token]/upload-url(2713d88): allowlist of audio / archive / doc / image / video extensions; blocks executables.safeFilenamecollapses..segments./api/upload/[token](2713d88): atomic cleanup — deletes the orphan storage object when the DB insert fails.
UX
- 7 Dutch error/UI strings translated to English (0556ce6).
- 11 em-dashes (—) replaced with plain ASCII hyphens (-) in user-facing copy (0556ce6).
- Two icon-only buttons (rename confirm/cancel, chat send) given
title+aria-labelfor screen readers (1c7a8cf).
Infrastructure
- All Vercel env vars now set on Production AND Preview tiers:
Stripe (4),
NEXT_PUBLIC_APP_URL,NEXT_PUBLIC_RELAY_URL, LiveKit (3), OpenAI, Service Role. Preview deploys now actually work for PR reviews. npm updateapplied patch + minor versions across 9 packages. Major bumps (Next 14→16, React 18→19, Tailwind 3→4, TS 5→6, lucide-react) deferred to the pre-launch sweep.
Pre-history
For commits before inbox1, see git log --before=2026-05-05.
The repo dates back to early 2026; the early period was rapid
prototyping of the live-session core (LiveKit + WebRTC + Connect
.exe) and the per-client archive flows.