Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/waveflow
# Upper bound on the sqlx pool. Default 20.
WAVEFLOW_DB_MAX_CONNECTIONS=20

# Phase 1.b auth shim — set to "1" to unlock the `X-User-Id` header
# auth on `/api/v1/users` and `/api/v1/profiles/*`. Default OFF
# (`/api/v1/*` returns 503). NEVER set this in production: the header
# is trivial to forge and would let an attacker pretend to be any
# user. Phase 1.d replaces this with JWT verification.
WAVEFLOW_DEV_AUTH=1
# Bearer-JWT auth (required) — set the full triple or boot fails.
# Point at the Better Auth instance (`waveflow-web`) issuing tokens
# for this server. Locally: the `/api/auth/jwks` endpoint exposed by
# the `jwt()` plugin + the matching `iss` / `aud` claims.
WAVEFLOW_JWT_JWKS_URL=http://localhost:3000/api/auth/jwks
WAVEFLOW_JWT_ISSUER=http://localhost:3000
WAVEFLOW_JWT_AUDIENCE=waveflow-server

# Logging.
# RUST_LOG syntax: `info,waveflow_server=debug,tower_http=debug`.
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ CI runs the full suite only on Linux (service container); the Windows leg is a c
- **`AppState`** (`src/lib.rs`) holds the shared singletons threaded through every handler — currently just the `PgPool` (cheap to clone, `Arc`-backed). Add new singletons here.
- **API is one file per resource** under `src/api/`, each exposing a `router()` merged in `src/api/mod.rs`. `/health` (liveness, no DB) and `/ready` (DB-aware readiness) are unversioned infra probes; every real resource mounts under `/api/v1/`.
- **No SQL in handlers.** SQL lives in the DB layer (`src/db.rs`) or in a `waveflow-core::repository::postgres::*` method; handlers stay pure HTTP orchestration. `db::ping` (`/ready`'s `SELECT 1`) and `db::users::create` are the in-tree pattern; everything tenant-scoped goes through `PostgresProfileRepository::*_for_user`. This mirrors the desktop's Tauri-command ↔ `waveflow-core` boundary.
- **Tenancy is enforced at the storage layer.** Server handlers under `/api/v1/*` extract `UserId` from the `require_user_id` middleware and call `*_for_user` methods only — never the single-tenant trait surface. `PostgresProfileRepository` deliberately does NOT implement `ProfileRepository`, so the compiler stops a careless `list_all()` from leaking another tenant's rows. Apply the same pattern when adding library / track / playlist repositories.
- **Auth: JWT + dev shim, transitioning.** `middleware::authenticate` runs JWT-first when [`AppState::jwt_verifier`] is configured: verify the Bearer, then `db::users::find_or_provision_by_external_id(state.db, &sub, now_ms)` to lazy-onboard the user on first request (Phase 1.c.3a — a valid signature is the authoritative onboarding signal, so no separate `POST /api/v1/users` is needed after Better Auth signup). Falls back to the legacy `X-User-Id` shim when `dev_auth_enabled`; returns 503 when neither path is configured (production gate). Phase 1.d.2 deletes the shim branch entirely.
- **Tenancy is enforced at the storage layer.** Server handlers under `/api/v1/*` extract `UserId` from the `middleware::authenticate` middleware and call `*_for_user` methods only — never the single-tenant trait surface. `PostgresProfileRepository` deliberately does NOT implement `ProfileRepository`, so the compiler stops a careless `list_all()` from leaking another tenant's rows. Apply the same pattern when adding library / track / playlist repositories.
- **Auth: JWT-only (Phase 1.d.2).** `middleware::authenticate` requires a Bearer JWT signed by the upstream Better Auth issuer. The middleware verifies the token via [`AppState::jwt_verifier`], then `db::users::find_or_provision_by_external_id(state.db, &sub, now_ms)` lazy-onboards the user on first request — a valid signature is the authoritative onboarding signal, so no separate `POST /api/v1/users` exists. Boot requires the full `WAVEFLOW_JWT_*` triple (`_JWKS_URL` / `_ISSUER` / `_AUDIENCE`); the legacy `X-User-Id` dev shim retired alongside `WAVEFLOW_DEV_AUTH`.
- **Don't leak DB errors to unauthenticated probes.** `/ready` logs the sqlx error via `tracing::warn!` but returns a fixed sentinel body (`{status, db}`) so a load balancer never sees the connection-URL host or credentials. Apply the same discipline to any other unauthenticated endpoint.
- **Migrations are immutable once merged.** They're embedded at compile time via `sqlx::migrate!("./migrations")` (`db::MIGRATOR`); the `_sqlx_migrations` table stores each file's checksum, so editing an applied migration makes the server refuse to start. Schema changes = a new dated migration file (`YYYYMMDDHHMMSS_name.sql`). Boot applies pending migrations *before* opening the listener, which is what makes `/ready` trustworthy.
- **Schema parity with the desktop SQLite migrations.** Postgres tables mirror the shapes in the desktop repo's `src-tauri/migrations/app/` so `PostgresProfileRepository` and `SqliteProfileRepository` (in `waveflow-core`) satisfy the same trait against identical rows. Keep types compatible (e.g. `BIGSERIAL` ↔ SQLite `INTEGER PK`, epoch-millis `BIGINT` for timestamps).
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Self-hosted backend for [WaveFlow](https://github.com/InstaZDLL/WaveFlow). Powers multi-device library sync, browser playback, public shareable playlists, and (later) the mobile app.

> **Status:** Phase 1.b.4tenant-scoped profile CRUD landed (`POST /api/v1/users`, full `/api/v1/profiles/*` with the dev `X-User-Id` header shim). Phase 1.d will swap the shim for JWT verification against Better Auth's JWKS. Track progress against the Phase 1 milestone on the main repo.
> **Status:** Phase 1.d.2JWT auth is the only path. Bearer tokens issued by [`waveflow-web`](https://github.com/InstaZDLL/waveflow-web)'s Better Auth instance are verified against its JWKS endpoint, and the first authenticated request from a fresh signup lazy-provisions the `users` row. Track progress against the Phase 1 milestone on the main repo.

## Architecture

Expand Down Expand Up @@ -33,10 +33,10 @@ cargo run
- `GET /ready` — readiness, `200 {status: "ready", db: "ok"}` when `SELECT 1` round-trips, `503 {status: "not_ready", db: "unavailable"}` otherwise. The sqlx error detail stays in the `tracing::warn!` log so an unauthenticated probe (e.g. a load balancer) doesn't see the connection-URL host or credentials.
- `GET /openapi.json` — OpenAPI 3.1 spec built from the handlers that carry both a `#[utoipa::path(...)]` annotation and a `routes!()` registration on the per-module `OpenApiRouter`. A plain `Router::route()` would mount the handler but leave it absent from the spec, so make sure new endpoints follow the same `routes!()` pattern as `/health` and `/ready`.
- `GET /reference` — [Scalar](https://github.com/scalar/scalar) API reference UI. Modern, dark-mode-native, integrated search. The OpenAPI spec it renders is the same one served at `/openapi.json`.
- `POST /api/v1/users` — mint a user row, returns `{id}`. Gated by the dev-auth shim (see below).
- `/api/v1/profiles/*` — full CRUD scoped to the calling user via the `X-User-Id` header. Tenant isolation enforced at the storage layer (`PostgresProfileRepository::*_for_user`), not just at the handler. `DELETE` refuses 409 if it would leave the user with zero profiles — same invariant the desktop's selector enforces client-side.
- `/api/v1/profiles/*` — full CRUD scoped to the calling user via the `Authorization: Bearer <jwt>` header. Tenant isolation enforced at the storage layer (`PostgresProfileRepository::*_for_user`), not just at the handler. `DELETE` refuses 409 if it would leave the user with zero profiles — same invariant the desktop's selector enforces client-side.
- `/api/v1/profiles/{profile_id}/libraries/*`, `/.../tracks/*`, `/api/v1/profiles/{profile_id}/playlists/*` — same auth + tenant-scoping pattern, nested per the resource tree.

> ⚠️ **Dev auth shim — production-off by default.** `/api/v1/*` returns `503 Service Unavailable` until `WAVEFLOW_DEV_AUTH=1` is set explicitly. With the gate on, every data route reads its tenant id from a forgeable `X-User-Id` request header — fine for local dev against a private Postgres, **never safe to expose on the public internet**. Phase 1.d retires both the flag and the shim by replacing the middleware with JWT verification against Better Auth's JWKS endpoint.
> 🔒 **Auth: JWT-only.** Every `/api/v1/*` request must carry an `Authorization: Bearer <jwt>` header signed by the configured Better Auth issuer. Boot requires the full `WAVEFLOW_JWT_JWKS_URL` / `WAVEFLOW_JWT_ISSUER` / `WAVEFLOW_JWT_AUDIENCE` triple — a missing knob fails fast at startup. The first authenticated request from a fresh `sub` lazy-provisions the `users` row, so there is no separate onboarding endpoint to hit.

### Running the tests

Expand Down
23 changes: 23 additions & 0 deletions migrations/20260531000000_users_external_id_not_null.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Phase 1.d.2: Better Auth is now the only configured auth path,
-- which means every `users` row exists because a Better Auth JWT
-- minted it (lazy-provisioned by `find_or_provision_by_external_id`
-- in the middleware). A NULL `external_id` is therefore a dangling
-- row — no JWT can ever authenticate against it — so the column
-- gets the NOT NULL constraint the lookup invariant always wanted.
--
-- The previous migration (20260530000005_users_external_id) added
-- the column as nullable so the Phase 1.b `X-User-Id` shim could
-- mint users via `POST /api/v1/users` without an upstream account.
-- The shim retires with this PR, so the nullable variant is
-- unreachable from production code.
--
-- Backfill: there is no install where this server runs in
-- production yet (1.c hasn't deployed), so the only rows with NULL
-- `external_id` are dev-time / test artifacts that the next test
-- run would have wiped anyway. Delete them outright rather than
-- inventing a synthetic external_id that no JWT could ever
-- resolve to.

DELETE FROM users WHERE external_id IS NULL;

ALTER TABLE users ALTER COLUMN external_id SET NOT NULL;
22 changes: 11 additions & 11 deletions src/api/libraries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//!
//! Same design as [`super::profiles`]: every handler reads
//! [`UserId`] from the request extension that
//! `middleware::require_user_id` attached, threads the path's
//! `middleware::authenticate` attached, threads the path's
//! `profile_id` straight through, and calls a `*_for_profile` method on
//! [`PostgresLibraryRepository`]. The repository SQL validates the
//! `library → profile → user` chain inline, so a request that targets
Expand Down Expand Up @@ -134,12 +134,12 @@ pub fn router(state: AppState) -> OpenApiRouter {
path = "/api/v1/profiles/{profile_id}/libraries",
tag = "libraries",
params(
("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"),
("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"),
("profile_id" = i64, Path, description = "Owning profile id"),
),
responses(
(status = 200, description = "Libraries under the profile, most-recently-updated first", body = Vec<LibraryResponse>),
(status = 401, description = "Missing or invalid X-User-Id"),
(status = 401, description = "Missing or invalid bearer token"),
(status = 500, description = "Database or internal failure (body is a plain-text reason)"),
),
)]
Expand Down Expand Up @@ -172,14 +172,14 @@ async fn list_libraries(
path = "/api/v1/profiles/{profile_id}/libraries",
tag = "libraries",
params(
("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"),
("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"),
("profile_id" = i64, Path, description = "Owning profile id"),
),
request_body = CreateLibraryRequest,
responses(
(status = 201, description = "Library created", body = LibraryResponse),
(status = 400, description = "Empty or whitespace-only `name` after trim"),
(status = 401, description = "Missing or invalid X-User-Id"),
(status = 401, description = "Missing or invalid bearer token"),
(status = 404, description = "Profile not owned by the calling user"),
(status = 500, description = "Database or internal failure (body is a plain-text reason)"),
),
Expand Down Expand Up @@ -234,13 +234,13 @@ async fn create_library(
path = "/api/v1/profiles/{profile_id}/libraries/{id}",
tag = "libraries",
params(
("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"),
("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"),
("profile_id" = i64, Path, description = "Owning profile id"),
("id" = i64, Path, description = "Library id"),
),
responses(
(status = 200, description = "Library found", body = LibraryResponse),
(status = 401, description = "Missing or invalid X-User-Id"),
(status = 401, description = "Missing or invalid bearer token"),
(status = 404, description = "No library with that id under the profile owned by the calling user"),
(status = 500, description = "Database or internal failure (body is a plain-text reason)"),
),
Expand Down Expand Up @@ -272,15 +272,15 @@ async fn get_library(
path = "/api/v1/profiles/{profile_id}/libraries/{id}",
tag = "libraries",
params(
("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"),
("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"),
("profile_id" = i64, Path, description = "Owning profile id"),
("id" = i64, Path, description = "Library id"),
),
request_body = UpdateLibraryRequest,
responses(
(status = 200, description = "Library updated", body = LibraryResponse),
(status = 400, description = "`name` was supplied but is empty / whitespace-only after trim"),
(status = 401, description = "Missing or invalid X-User-Id"),
(status = 401, description = "Missing or invalid bearer token"),
(status = 404, description = "No library with that id under the profile owned by the calling user"),
(status = 500, description = "Database or internal failure (body is a plain-text reason)"),
),
Expand Down Expand Up @@ -337,13 +337,13 @@ async fn update_library(
path = "/api/v1/profiles/{profile_id}/libraries/{id}",
tag = "libraries",
params(
("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"),
("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"),
("profile_id" = i64, Path, description = "Owning profile id"),
("id" = i64, Path, description = "Library id"),
),
responses(
(status = 204, description = "Library deleted"),
(status = 401, description = "Missing or invalid X-User-Id"),
(status = 401, description = "Missing or invalid bearer token"),
(status = 404, description = "No library with that id under the profile owned by the calling user"),
(status = 500, description = "Database or internal failure (body is a plain-text reason)"),
),
Expand Down
Loading
Loading