Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f09151c
feat(api): add /api/session and HEAD /health endpoints to support Sve…
ebigunso Aug 13, 2025
e416698
refactor(api): remove server-rendered HTML (/login GET, /trends) and …
ebigunso Aug 13, 2025
7fd4361
feat(ui): scaffold SvelteKit app with Tailwind, Vite proxy, auth flow…
ebigunso Aug 13, 2025
3232758
chore(ui): add Dockerfile to run SvelteKit dev server in Docker and e…
ebigunso Aug 13, 2025
9cc4c08
fix(ui): correct vitePreprocess import, align tsconfig to extend .sve…
ebigunso Aug 13, 2025
95428b1
fix(sleep-ui): migrate Tailwind to v4 CSS-first + PostCSS plugin, res…
ebigunso Aug 17, 2025
821d37b
chore: stage pending compose change and add sleep-ui e2e test
ebigunso Aug 17, 2025
69fdd23
:construction: Add lock file
ebigunso Aug 17, 2025
c355ea4
fix(tailwind): correct directive order and @config path for Tailwind …
ebigunso Aug 17, 2025
63cc62e
chore(tailwind): switch to @tailwindcss/vite to eliminate PostCSS fro…
ebigunso Aug 17, 2025
f18e09c
chore(tailwind): remove PostCSS config and deps after migrating to @t…
ebigunso Aug 17, 2025
86b222f
:art: Format
ebigunso Aug 17, 2025
571d645
:recycle: Fix clippy warnings
ebigunso Aug 17, 2025
a6650c7
:art: Format
ebigunso Aug 17, 2025
d83b8cd
ui: secure toast ID fallback and typed chart.js dynamic import
ebigunso Aug 17, 2025
c7adb2a
ui: explicit feature detection for crypto.randomUUID in toast store (…
ebigunso Aug 17, 2025
47c1dea
API: make DELETE /sleep/{id} idempotent (always 204); add /api/sessio…
ebigunso Aug 17, 2025
30f2330
UI: enforce SSR auth guard via +layout.server.ts; remove client-side …
ebigunso Aug 17, 2025
7e15d19
UI: remove unused deps; make Dockerfile deterministic with npm ci and…
ebigunso Aug 17, 2025
4898983
Tests: add HEAD /health and /api/session e2e checks; verify idempoten…
ebigunso Aug 17, 2025
84e44c2
UI: update package-lock.json after dependency cleanup
ebigunso Aug 17, 2025
0c4acc9
:memo: Update changelog
ebigunso Aug 17, 2025
1edef1e
UI: switch login submit to POST /auth/login (form-urlencoded); SSR: r…
ebigunso Aug 17, 2025
7ebd5df
Dev proxy: use /auth only in dev; UI derives AUTH_PREFIX so prod uses…
ebigunso Aug 17, 2025
de619a3
:art: Format
ebigunso Aug 18, 2025
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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,12 @@ target

.env
.env.docker

# Frontend (SvelteKit)
sleep-ui/node_modules
sleep-ui/.svelte-kit
sleep-ui/build
sleep-ui/.vite
sleep-ui/.env
sleep-ui/playwright-report
sleep-ui/test-results
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@ This project adheres to Keep a Changelog and uses Semantic Versioning.
- DST-aware behavior explained for time::compute_duration_min with example (C-EXAMPLE).
- Cargo metadata fields in sleep-api/Cargo.toml (authors, description, license, repository, keywords, categories) (C-METADATA).
- Link to Release Notes from crate-level documentation (C-RELNOTES).
- API: Added /api/session (GET) session probe and HEAD /health; OpenAPI updated accordingly.
- UI: Server-side auth guard in SvelteKit (+layout.server.ts) redirects unauthenticated requests to /login to prevent SSR of protected pages.

### Changed
- trends_page error handling to log template rendering errors and avoid unwraps in application code.
- Intra-doc links added between related items (e.g., models ↔ repository ↔ time) (C-LINK).
- Backend: Root "/" now returns 204 No Content (API-only; HTML removed). DELETE /sleep/{id} is idempotent and always returns 204 when authorized.
- UI: Dev Dockerfile now uses package-lock.json with npm ci for deterministic builds; vite proxy includes /login; removed unused deps (@vite-pwa/sveltekit, zod, @types/cookie).
- Tests: Added end-to-end checks for /api/session pre/post login and after logout; HEAD /health; and idempotent DELETE behavior.

### Hidden
- Marked impl From<DomainError> for ApiError as #[doc(hidden)] to avoid surfacing non-actionable internals in public docs (C-HIDDEN).
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,29 @@ The API applies the following headers to all responses:
- TODO: Move to nonces/hashes and remove 'unsafe-inline' when templates are adjusted
- Strict-Transport-Security (HSTS) when ENABLE_HSTS=1/true

## SvelteKit UI (frontend)

For local UI development:
- cd sleep-ui
- npm ci
- npm run dev

The dev server runs at http://localhost:5173 and proxies API calls to http://localhost:8080 via vite.config.ts. Authentication is cookie-based with CSRF double-submit.

Server-side route protection:
- +layout.server.ts fetches /api/session during SSR and redirects unauthenticated requests to /login. This prevents rendering protected pages on the server and avoids client-side flashes.

Local HTTP note:
- For local HTTP development, set COOKIE_SECURE=0 in the API environment so non-__Host- cookies are accepted over http. Do not use this setting in production.

## OpenAPI

OpenAPI specification is in openapi.yaml and includes:
- /login and /logout endpoints
- Cookie-based session authentication scheme
- Double-submit CSRF requirement (X-CSRF-Token) on mutating endpoints
- /api/session endpoint for session probe (GET)
- HEAD /health endpoint

## Building, formatting, linting, testing

Expand Down
11 changes: 11 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,16 @@ services:
volumes:
- sleep_data:/data

ui:
build: ./sleep-ui
working_dir: /app
ports:
- "5173:5173"
volumes:
- ./sleep-ui:/app
- /app/node_modules
depends_on:
- api

volumes:
sleep_data:
22 changes: 21 additions & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,26 @@ paths:
responses:
'200':
description: OK
head:
responses:
'200':
description: OK
/api/session:
get:
summary: Session probe
security:
- cookieAuth: []
responses:
'200':
description: Authenticated flag
content:
application/json:
schema:
type: object
properties:
authenticated:
type: boolean

/sleep:
post:
requestBody:
Expand Down Expand Up @@ -169,7 +189,7 @@ paths:
csrfHeader: []
responses:
'204':
description: Deleted
description: Deleted or already absent
'401':
description: Unauthorized
content:
Expand Down
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

96 changes: 28 additions & 68 deletions sleep-api/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ For an end-to-end server setup example, see [`router`].
[`Router`]: axum::Router
"#]

use crate::auth::{self, LoginPayload};
use crate::middleware::auth_layer::{RequireSessionJson, RequireSessionRedirect};
use crate::auth::{self, LoginPayload, current_user_from_cookie};
use crate::middleware::auth_layer::RequireSessionJson;
use crate::security::csrf::{CsrfGuard, issue_csrf_cookie};
use crate::{
db::Db,
Expand All @@ -21,7 +21,6 @@ use crate::{
models::{ExerciseInput, NoteInput, SleepInput},
trends,
};
use askama::Template;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Redirect};
use axum::{
Expand All @@ -36,6 +35,11 @@ use serde_json::json;

Routes:
- `GET /health`
- `HEAD /health`
- `POST /login`
- `POST /login.json`
- `POST /logout`
- `GET /api/session`
- `POST /sleep`
- `GET /sleep/date/{date}`
- `PUT /sleep/{id}`
Expand All @@ -44,7 +48,6 @@ Routes:
- `POST /note`
- `GET /api/trends/sleep-bars`
- `GET /api/trends/summary`
- `GET /trends`

# Example

Expand Down Expand Up @@ -114,61 +117,46 @@ pub fn router(db: Db) -> Router {
};
let router = Router::new()
.route("/", get(root))
.route("/health", get(|| async { Json(json!({"status":"ok"})) }))
.route("/login", get(get_login).post(post_login))
.route("/health", get(health_get).head(health_head))
.route("/login", post(post_login))
.route("/login.json", post(post_login_json))
.route("/logout", post(post_logout))
.route("/api/session", get(api_session))
.route("/sleep", post(create_sleep))
.route("/sleep/date/{date}", get(get_sleep))
.route("/sleep/{id}", put(update_sleep).delete(delete_sleep))
.route("/exercise", post(create_exercise))
.route("/note", post(create_note))
.route("/api/trends/sleep-bars", get(trends::sleep_bars))
.route("/api/trends/summary", get(trends::summary))
.route("/trends", get(trends_page))
.with_state(state);

crate::security::headers::apply(router, enable_hsts)
}

#[doc = r#"Redirect root to /trends.

Security:
- Requires an authenticated session (via [`RequireSessionRedirect`]). Unauthenticated users are redirected to `/login`.

Responses:
- 303 See Other — redirects to `/trends`
// Health endpoints for SvelteKit UI
async fn health_get() -> Json<serde_json::Value> {
Json(json!({"status":"ok"}))
}
async fn health_head() -> StatusCode {
StatusCode::OK
}

See also: [`trends_page`], [`crate::middleware::auth_layer::RequireSessionRedirect`]
"#]
async fn root(RequireSessionRedirect { _user_id: _ }: RequireSessionRedirect) -> Redirect {
Redirect::to("/trends")
// Session probe for UI
async fn api_session(jar: PrivateCookieJar) -> Json<serde_json::Value> {
let authed = current_user_from_cookie(&jar).is_some();
Json(json!({"authenticated": authed}))
}

#[doc = r#"Render a minimal HTML login form.
#[doc = r#"Root endpoint.

Accepts: `GET /login`
- Returns an HTML page with a form that POSTs to `/login`.
Returns 204 No Content. This API-only server does not serve HTML; the UI is a separate SvelteKit app.

Responses:
- 200 OK — HTML page

See also: [`post_login`], [`crate::auth::verify_login`]
- 204 No Content
"#]
async fn get_login() -> Html<String> {
let html = r#"<!doctype html>
<html>
<head><meta charset="utf-8"><title>Login</title></head>
<body>
<h1>Login</h1>
<form method="post" action="/login">
<label>Email <input type="email" name="email" /></label><br/>
<label>Password <input type="password" name="password" /></label><br/>
<button type="submit">Login</button>
</form>
</body>
</html>"#;
Html(html.to_string())
async fn root() -> StatusCode {
StatusCode::NO_CONTENT
}

#[doc = r#"Login (form) and issue session + CSRF cookies.
Expand Down Expand Up @@ -396,12 +384,8 @@ async fn delete_sleep(
RequireSessionJson { _user_id: _ }: RequireSessionJson,
_csrf: CsrfGuard,
) -> Result<impl axum::response::IntoResponse, ApiError> {
let affected = handlers::delete_sleep(&db, id).await?;
if affected == 0 {
Err(ApiError::NotFound)
} else {
Ok(StatusCode::NO_CONTENT)
}
let _affected = handlers::delete_sleep(&db, id).await?;
Ok(StatusCode::NO_CONTENT)
}

#[doc = r#"Create an exercise entry.
Expand Down Expand Up @@ -455,27 +439,3 @@ async fn create_note(
let id = handlers::create_note(&db, input).await?;
Ok((StatusCode::CREATED, Json(json!({"id": id}))))
}

#[doc = r#"Render the trends page (Askama template).

Security:
- Requires authenticated session ([`RequireSessionRedirect`]); unauthenticated users are redirected to `/login`.

Responses:
- 200 OK — HTML page
- Redirect — when not authenticated

See also: [`crate::views::TrendsTemplate`], [`crate::middleware::auth_layer::RequireSessionRedirect`]
"#]
async fn trends_page(
RequireSessionRedirect { _user_id: _ }: RequireSessionRedirect,
) -> Html<String> {
let tpl = super::views::TrendsTemplate;
match tpl.render() {
Ok(html) => Html(html),
Err(e) => {
tracing::error!("Template rendering error: {}", e);
Html("An internal error occurred while rendering the page.".to_string())
}
}
}
4 changes: 1 addition & 3 deletions sleep-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Key modules:
- [`models`] — input/output types with validation.
- [`repository`] — persistence operations.
- [`time`] — time and duration helpers including DST‑aware computations.
- [`trends`] and [`views`] — aggregation endpoints and templates.
- [`trends`] — aggregation endpoints.

Why: use this crate to embed the API server in your binary, or reuse its types and helpers like [`compute_duration_min`].

Expand Down Expand Up @@ -41,7 +41,6 @@ See also: [`time`], [`repository`], and [`models`].
[`repository`]: crate::repository
[`time`]: crate::time
[`trends`]: crate::trends
[`views`]: crate::views
[`compute_duration_min`]: crate::time::compute_duration_min
"#]

Expand All @@ -58,4 +57,3 @@ pub mod repository;
pub mod security;
pub mod time;
pub mod trends;
pub mod views;
1 change: 0 additions & 1 deletion sleep-api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ mod repository;
mod security;
mod time;
mod trends;
mod views;

use crate::db::connect;
use tokio::net::TcpListener;
Expand Down
41 changes: 2 additions & 39 deletions sleep-api/src/middleware/auth_layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,20 @@

Provides extractors to require a valid session:
- [`RequireSessionJson`] → returns `401` JSON (`{"error":"unauthorized"}`) on failure
- [`RequireSessionRedirect`] → redirects to `/login` on failure (for UI routes)

These extractors read the encrypted `__Host-session` cookie via [`PrivateCookieJar`]. They require that the application state implements [`FromRef`] for [`Key`], which is provided by [`app::AppState`].

# Example

```rust,no_run
# use axum::{Json, response::IntoResponse};
# use sleep_api::middleware::auth_layer::{RequireSessionJson, RequireSessionRedirect};
# use sleep_api::middleware::auth_layer::RequireSessionJson;
# async fn api_handler(
# RequireSessionJson { _user_id: _ }: RequireSessionJson,
# Json(_): Json<serde_json::Value>,
# ) -> impl IntoResponse {
# axum::http::StatusCode::NO_CONTENT
# }
# async fn ui_handler(
# RequireSessionRedirect { _user_id: _ }: RequireSessionRedirect,
# ) -> axum::response::Html<String> {
# axum::response::Html(String::new())
# }
```

See also:
Expand All @@ -31,7 +25,7 @@ See also:

use axum::extract::{FromRef, FromRequestParts};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use axum::response::{IntoResponse, Response};
use axum_extra::extract::cookie::{Key, PrivateCookieJar};
use serde_json::json;

Expand All @@ -43,12 +37,6 @@ pub struct RequireSessionJson {
pub _user_id: UserId,
}

/// Extractor that requires an authenticated session for UI routes.
/// On failure, redirects to /login.
pub struct RequireSessionRedirect {
pub _user_id: UserId,
}

impl<S> FromRequestParts<S> for RequireSessionJson
where
S: Send + Sync,
Expand All @@ -70,35 +58,10 @@ where
}
}

impl<S> FromRequestParts<S> for RequireSessionRedirect
where
S: Send + Sync,
Key: FromRef<S>,
{
type Rejection = Response;

async fn from_request_parts(
parts: &mut axum::http::request::Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
let jar = PrivateCookieJar::from_request_parts(parts, state)
.await
.map_err(|_| redirect_login())?;
match current_user_from_cookie(&jar) {
Some(uid) => Ok(Self { _user_id: uid }),
None => Err(redirect_login()),
}
}
}

fn unauthorized() -> Response {
(
StatusCode::UNAUTHORIZED,
axum::Json(json!({"error":"unauthorized"})),
)
.into_response()
}

fn redirect_login() -> Response {
Redirect::to("/login").into_response()
}
Loading