fabrica

hanna/fabrica

feat(api): /api/v1 JSON surface with JWT auth and scopes

c21dfed · hanna committed on 2026-07-25

Add the API crate: a self-contained axum router nested by web at
/api/v1. A Bearer-JWT extractor verifies the signature and expiry, that
the jti row exists and is unrevoked, and that the account is enabled,
bumping last_used_at — so revocation is real. Scopes gate each handler
(admin grants all); a private resource the token cannot reach returns
404, never 403.

Endpoints: version, user, users (admin), repos (list + create), repo
detail and git sub-resources (branches/tags/commits/commit/tree/raw),
delete (with ?purge), groups, and a name/description search. Errors share
{"error":{code,message,details}}; timestamps are RFC 3339; lists carry
X-Total-Count and ?page/per_page (capped 100).

web nests it via nest_service (web -> api is allowed; nothing depends on
web). Verified end-to-end: 401 without a token, RFC3339 output, git
sub-resources, POST 201, DELETE 204, and the error shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +834 −1UnifiedSplit
Cargo.lock +14 −0
@@ -198,6 +198,19 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
198198 [[package]]
199199 name = "api"
200200 version = "0.1.0"
201+dependencies = [
202+ "auth",
203+ "axum",
204+ "config",
205+ "git",
206+ "model",
207+ "serde",
208+ "serde_json",
209+ "store",
210+ "time",
211+ "tokio",
212+ "tracing",
213+]
201214
202215 [[package]]
203216 name = "argon2"
@@ -5570,6 +5583,7 @@ name = "web"
55705583 version = "0.1.0"
55715584 dependencies = [
55725585 "ammonia",
5586+ "api",
55735587 "auth",
55745588 "axum",
55755589 "axum-extra",
crates/api/Cargo.toml +11 −0
@@ -10,6 +10,17 @@ repository.workspace = true
1010 publish.workspace = true
1111
1212 [dependencies]
13+config = { workspace = true }
14+store = { workspace = true }
15+model = { workspace = true }
16+git = { workspace = true }
17+auth = { workspace = true }
18+axum = { workspace = true }
19+serde = { workspace = true }
20+serde_json = { workspace = true }
21+time = { workspace = true }
22+tokio = { workspace = true }
23+tracing = { workspace = true }
1324
1425 [lints]
1526 workspace = true
crates/api/src/handlers.rs +527 −0
@@ -0,0 +1,527 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! The `/api/v1` handlers.
6+
7+use std::path::Path as FsPath;
8+
9+use auth::{Access, Scope};
10+use axum::Json;
11+use axum::extract::{Path, Query, State};
12+use axum::http::{StatusCode, header};
13+use axum::response::{IntoResponse, Response};
14+use model::{Repo, User};
15+use serde::Deserialize;
16+use serde_json::{Value, json};
17+
18+use crate::{ApiAuth, ApiError, ApiResult, ApiState, git_read, rfc3339, rfc3339_opt};
19+
20+/// Pagination + ref query parameters.
21+#[derive(Debug, Default, Deserialize)]
22+pub struct ListParams {
23+ page: Option<usize>,
24+ per_page: Option<usize>,
25+ #[serde(rename = "ref")]
26+ reference: Option<String>,
27+ #[serde(default)]
28+ q: Option<String>,
29+ #[serde(default)]
30+ purge: Option<bool>,
31+}
32+
33+impl ListParams {
34+ /// The zero-based offset and page size (`per_page` capped at 100).
35+ fn page(&self) -> (usize, usize) {
36+ let per_page = self.per_page.unwrap_or(30).clamp(1, 100);
37+ let page = self.page.unwrap_or(1).max(1);
38+ ((page - 1) * per_page, per_page)
39+ }
40+}
41+
42+/// `GET /version`.
43+pub async fn version(State(state): State<ApiState>) -> Json<Value> {
44+ Json(json!({
45+ "name": state.config.instance.name,
46+ "version": env!("CARGO_PKG_VERSION"),
47+ }))
48+}
49+
50+/// `GET /user` — the current token's user.
51+pub async fn current_user(auth: ApiAuth) -> ApiResult<Json<Value>> {
52+ Ok(Json(user_json(&auth.user)))
53+}
54+
55+/// `GET /users` — admin only.
56+pub async fn list_users(State(state): State<ApiState>, auth: ApiAuth) -> ApiResult<Response> {
57+ auth.require(Scope::Admin)?;
58+ let users = state.store.list_users().await?;
59+ let total = users.len();
60+ let (offset, limit) = ListParams::default().page();
61+ let view: Vec<Value> = users
62+ .iter()
63+ .skip(offset)
64+ .take(limit)
65+ .map(user_json)
66+ .collect();
67+ Ok(with_total(Json(view), total))
68+}
69+
70+/// `GET /repos` — repos visible to the token.
71+pub async fn list_repos(
72+ State(state): State<ApiState>,
73+ auth: ApiAuth,
74+ Query(params): Query<ListParams>,
75+) -> ApiResult<Response> {
76+ auth.require(Scope::RepoRead)?;
77+ let mut visible = Vec::new();
78+ for repo in state.store.list_repos().await? {
79+ if repo_access(&state, &auth, &repo).await? >= Access::Read {
80+ visible.push(repo);
81+ }
82+ }
83+ let total = visible.len();
84+ let (offset, limit) = params.page();
85+ let mut view = Vec::new();
86+ for repo in visible.into_iter().skip(offset).take(limit) {
87+ let owner = owner_name(&state, &repo.owner_id).await;
88+ view.push(repo_json(&repo, &owner));
89+ }
90+ Ok(with_total(Json(view), total))
91+}
92+
93+/// New-repo request body.
94+#[derive(Debug, Deserialize)]
95+pub struct CreateRepo {
96+ owner: Option<String>,
97+ name: String,
98+ #[serde(default)]
99+ private: Option<bool>,
100+ #[serde(default)]
101+ description: Option<String>,
102+ #[serde(default)]
103+ default_branch: Option<String>,
104+}
105+
106+/// `POST /repos`.
107+pub async fn create_repo(
108+ State(state): State<ApiState>,
109+ auth: ApiAuth,
110+ Json(body): Json<CreateRepo>,
111+) -> ApiResult<Response> {
112+ auth.require(Scope::RepoWrite)?;
113+ // Resolve the owner; only an admin may create for someone else.
114+ let owner = match &body.owner {
115+ Some(name) => state
116+ .store
117+ .user_by_username(name)
118+ .await?
119+ .ok_or(ApiError::NotFound)?,
120+ None => auth.user.clone(),
121+ };
122+ if owner.id != auth.user.id && !auth.user.is_admin {
123+ return Err(ApiError::Forbidden("cannot create repos for another user"));
124+ }
125+
126+ let (group_path, leaf) = match body.name.rsplit_once('/') {
127+ Some((g, l)) => (Some(g.to_string()), l.to_string()),
128+ None => (None, body.name.clone()),
129+ };
130+ let group_id = match &group_path {
131+ Some(g) => Some(state.store.ensure_group_path(&owner.id, g).await?.id),
132+ None => None,
133+ };
134+ let branch = body
135+ .default_branch
136+ .unwrap_or_else(|| state.config.instance.default_branch.clone());
137+
138+ let repo = state
139+ .store
140+ .create_repo(store::NewRepo {
141+ owner_id: owner.id.clone(),
142+ group_id,
143+ name: leaf,
144+ path: body.name.clone(),
145+ description: body.description,
146+ is_private: body.private.unwrap_or(true),
147+ default_branch: branch.clone(),
148+ })
149+ .await?;
150+
151+ let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica"));
152+ if let Err(err) = git::create_bare(&state.config.storage.repo_dir, &repo.id, &branch, &hook) {
153+ let _ = state.store.delete_repo(&repo.id).await;
154+ return Err(ApiError::internal(err));
155+ }
156+
157+ Ok((StatusCode::CREATED, Json(repo_json(&repo, &owner.username))).into_response())
158+}
159+
160+/// A parsed repo sub-resource.
161+enum Sub {
162+ Detail,
163+ Branches,
164+ Tags,
165+ Commits,
166+ Commit(String),
167+ Tree(String, String),
168+ Raw(String, String),
169+}
170+
171+/// Split `{*rest}` into the repo path and its sub-resource. The first segment that
172+/// is a known keyword ends the repo path (repos should not be named after these
173+/// keywords — a documented MVP limitation).
174+fn split_rest(rest: &str) -> (String, Sub) {
175+ let segs: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
176+ let keyword = segs
177+ .iter()
178+ .position(|s| matches!(*s, "branches" | "tags" | "commits" | "tree" | "raw"));
179+ let Some(i) = keyword else {
180+ return (segs.join("/"), Sub::Detail);
181+ };
182+ let repo_path = segs[..i].join("/");
183+ let rest = &segs[i + 1..];
184+ let sub = match segs[i] {
185+ "branches" => Sub::Branches,
186+ "tags" => Sub::Tags,
187+ "commits" => rest
188+ .first()
189+ .map_or(Sub::Commits, |sha| Sub::Commit((*sha).to_string())),
190+ "tree" => Sub::Tree(
191+ rest.first().copied().unwrap_or_default().to_string(),
192+ rest.get(1..).map(|s| s.join("/")).unwrap_or_default(),
193+ ),
194+ _ => Sub::Raw(
195+ rest.first().copied().unwrap_or_default().to_string(),
196+ rest.get(1..).map(|s| s.join("/")).unwrap_or_default(),
197+ ),
198+ };
199+ (repo_path, sub)
200+}
201+
202+/// `GET /repos/{owner}/{*rest}` — repo detail or a git sub-resource.
203+pub async fn repo_get(
204+ State(state): State<ApiState>,
205+ auth: ApiAuth,
206+ Query(params): Query<ListParams>,
207+ Path((owner, rest)): Path<(String, String)>,
208+) -> ApiResult<Response> {
209+ auth.require(Scope::RepoRead)?;
210+ let (repo_path, sub) = split_rest(&rest);
211+ let repo = resolve_repo(&state, &auth, &owner, &repo_path, Access::Read).await?;
212+ let default_ref = repo.default_branch.clone();
213+
214+ match sub {
215+ Sub::Detail => Ok(Json(repo_json(&repo, &owner)).into_response()),
216+ Sub::Branches => {
217+ let branches = git_read(&state, &repo.id, git::Repo::branches).await?;
218+ let view: Vec<Value> = branches
219+ .iter()
220+ .map(|b| {
221+ json!({
222+ "name": b.name, "commit": b.oid.as_str(), "default": b.is_default,
223+ "ahead": b.ahead, "behind": b.behind,
224+ })
225+ })
226+ .collect();
227+ Ok(Json(view).into_response())
228+ }
229+ Sub::Tags => {
230+ let tags = git_read(&state, &repo.id, git::Repo::tags).await?;
231+ let view: Vec<Value> = tags
232+ .iter()
233+ .map(|t| json!({ "name": t.name, "commit": t.oid.as_str(), "annotated": t.annotated }))
234+ .collect();
235+ Ok(Json(view).into_response())
236+ }
237+ Sub::Commits => {
238+ let (offset, limit) = params.page();
239+ let reference = params.reference.unwrap_or(default_ref);
240+ let commits = git_read(&state, &repo.id, move |repo| {
241+ let rev = repo.resolve_ref(&reference)?;
242+ repo.commits(&rev, None, git::Page { offset, limit })
243+ })
244+ .await?;
245+ let view: Vec<Value> = commits.iter().map(commit_summary_json).collect();
246+ Ok(Json(view).into_response())
247+ }
248+ Sub::Commit(sha) => {
249+ let detail = git_read(&state, &repo.id, move |repo| {
250+ repo.commit(&git::Oid::new(sha))
251+ })
252+ .await?;
253+ Ok(Json(commit_detail_json(&detail)).into_response())
254+ }
255+ Sub::Tree(reference, path) => {
256+ let reference = if reference.is_empty() {
257+ default_ref
258+ } else {
259+ reference
260+ };
261+ let entries = git_read(&state, &repo.id, move |repo| {
262+ let rev = repo.resolve_ref(&reference)?;
263+ repo.tree_entries(&rev, FsPath::new(&path))
264+ })
265+ .await?;
266+ let view: Vec<Value> = entries
267+ .iter()
268+ .map(|e| {
269+ json!({
270+ "name": e.name, "kind": entry_kind(e.kind),
271+ "mode": format!("{:o}", e.mode), "oid": e.oid.as_str(), "size": e.size,
272+ })
273+ })
274+ .collect();
275+ Ok(Json(view).into_response())
276+ }
277+ Sub::Raw(reference, path) => {
278+ let reference = if reference.is_empty() {
279+ default_ref
280+ } else {
281+ reference
282+ };
283+ let path2 = path.clone();
284+ let blob = git_read(&state, &repo.id, move |repo| {
285+ let rev = repo.resolve_ref(&reference)?;
286+ repo.blob(&rev, FsPath::new(&path2))
287+ })
288+ .await?;
289+ let content_type = if blob.is_binary {
290+ "application/octet-stream"
291+ } else {
292+ "text/plain; charset=utf-8"
293+ };
294+ Ok((
295+ [
296+ (header::CONTENT_TYPE, content_type),
297+ (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
298+ ],
299+ blob.content,
300+ )
301+ .into_response())
302+ }
303+ }
304+}
305+
306+/// `DELETE /repos/{owner}/{*rest}?purge=`.
307+pub async fn repo_delete(
308+ State(state): State<ApiState>,
309+ auth: ApiAuth,
310+ Query(params): Query<ListParams>,
311+ Path((owner, rest)): Path<(String, String)>,
312+) -> ApiResult<Response> {
313+ auth.require(Scope::RepoAdmin)?;
314+ let (repo_path, _) = split_rest(&rest);
315+ let repo = resolve_repo(&state, &auth, &owner, &repo_path, Access::Admin).await?;
316+
317+ state.store.delete_repo(&repo.id).await?;
318+ if let Ok(dir) = git::repo_path(&state.config.storage.repo_dir, &repo.id) {
319+ if dir.exists() {
320+ if params.purge.unwrap_or(false) {
321+ let _ = std::fs::remove_dir_all(&dir);
322+ } else {
323+ let trash = state.config.storage.data_dir.join("trash").join(format!(
324+ "{}-{}.git",
325+ repo.id,
326+ crate::now_secs()
327+ ));
328+ if let Some(parent) = trash.parent() {
329+ let _ = std::fs::create_dir_all(parent);
330+ }
331+ let _ = std::fs::rename(&dir, &trash);
332+ }
333+ }
334+ }
335+ Ok(StatusCode::NO_CONTENT.into_response())
336+}
337+
338+/// `GET /groups/{owner}`.
339+pub async fn list_groups(
340+ State(state): State<ApiState>,
341+ auth: ApiAuth,
342+ Path(owner): Path<String>,
343+) -> ApiResult<Response> {
344+ auth.require(Scope::RepoRead)?;
345+ let owner_user = state
346+ .store
347+ .user_by_username(&owner)
348+ .await?
349+ .ok_or(ApiError::NotFound)?;
350+ let groups = state.store.groups_by_owner(&owner_user.id).await?;
351+ let view: Vec<Value> = groups
352+ .iter()
353+ .map(|g| json!({ "name": g.name, "path": g.path, "description": g.description }))
354+ .collect();
355+ Ok(Json(view).into_response())
356+}
357+
358+/// `GET /search?q=` — repos and users matching the query (name/description).
359+pub async fn search(
360+ State(state): State<ApiState>,
361+ auth: ApiAuth,
362+ Query(params): Query<ListParams>,
363+) -> ApiResult<Response> {
364+ auth.require(Scope::RepoRead)?;
365+ let needle = params.q.unwrap_or_default().to_lowercase();
366+ if needle.is_empty() {
367+ return Ok(Json(json!({ "repos": [], "users": [] })).into_response());
368+ }
369+ let mut repos = Vec::new();
370+ for repo in state.store.list_repos().await? {
371+ if repo_access(&state, &auth, &repo).await? < Access::Read {
372+ continue;
373+ }
374+ let owner = owner_name(&state, &repo.owner_id).await;
375+ let hay = format!(
376+ "{}/{} {}",
377+ owner,
378+ repo.path,
379+ repo.description.clone().unwrap_or_default()
380+ )
381+ .to_lowercase();
382+ if hay.contains(&needle) {
383+ repos.push(repo_json(&repo, &owner));
384+ }
385+ }
386+ let users: Vec<Value> = state
387+ .store
388+ .list_users()
389+ .await?
390+ .iter()
391+ .filter(|u| u.username.to_lowercase().contains(&needle))
392+ .map(user_json)
393+ .collect();
394+ Ok(Json(json!({ "repos": repos, "users": users })).into_response())
395+}
396+
397+/// The API fallback for unmatched routes.
398+pub async fn not_found() -> ApiError {
399+ ApiError::NotFound
400+}
401+
402+// ---- helpers ----
403+
404+/// Resolve `(owner, path)` to a repo the token may access at `required`, else 404.
405+async fn resolve_repo(
406+ state: &ApiState,
407+ auth: &ApiAuth,
408+ owner: &str,
409+ repo_path: &str,
410+ required: Access,
411+) -> ApiResult<Repo> {
412+ let owner_user = state
413+ .store
414+ .user_by_username(owner)
415+ .await?
416+ .ok_or(ApiError::NotFound)?;
417+ let repo = state
418+ .store
419+ .repo_by_owner_path(&owner_user.id, repo_path)
420+ .await?
421+ .ok_or(ApiError::NotFound)?;
422+ if repo_access(state, auth, &repo).await? >= required {
423+ Ok(repo)
424+ } else {
425+ Err(ApiError::NotFound) // 404, never 403, for a private resource.
426+ }
427+}
428+
429+/// The token's access level to a repo.
430+async fn repo_access(state: &ApiState, auth: &ApiAuth, repo: &Repo) -> ApiResult<Access> {
431+ let collaborator = state
432+ .store
433+ .collaborator_permission(&repo.id, &auth.user.id)
434+ .await?
435+ .and_then(|p| auth::Permission::parse(&p));
436+ Ok(auth::access(
437+ Some(&auth.viewer()),
438+ repo,
439+ collaborator,
440+ state.config.instance.allow_anonymous,
441+ ))
442+}
443+
444+/// Resolve an owner id to a username.
445+async fn owner_name(state: &ApiState, owner_id: &str) -> String {
446+ state
447+ .store
448+ .user_by_id(owner_id)
449+ .await
450+ .ok()
451+ .flatten()
452+ .map_or_else(|| owner_id.to_string(), |u| u.username)
453+}
454+
455+/// Attach an `X-Total-Count` header to a JSON list response.
456+fn with_total(json: Json<Vec<Value>>, total: usize) -> Response {
457+ (
458+ [(
459+ header::HeaderName::from_static("x-total-count"),
460+ header::HeaderValue::from(u64::try_from(total).unwrap_or(0)),
461+ )],
462+ json,
463+ )
464+ .into_response()
465+}
466+
467+/// Render a user (never the password hash).
468+fn user_json(user: &User) -> Value {
469+ json!({
470+ "username": user.username,
471+ "email": user.email,
472+ "display_name": user.display_name,
473+ "is_admin": user.is_admin,
474+ "disabled": user.disabled_at.is_some(),
475+ "created_at": rfc3339(user.created_at),
476+ })
477+}
478+
479+/// Render a repo.
480+fn repo_json(repo: &Repo, owner: &str) -> Value {
481+ json!({
482+ "id": repo.id,
483+ "owner": owner,
484+ "name": repo.name,
485+ "path": repo.path,
486+ "private": repo.is_private,
487+ "default_branch": repo.default_branch,
488+ "description": repo.description,
489+ "archived": repo.archived_at.is_some(),
490+ "size_bytes": repo.size_bytes,
491+ "pushed_at": rfc3339_opt(repo.pushed_at),
492+ "created_at": rfc3339(repo.created_at),
493+ "updated_at": rfc3339(repo.updated_at),
494+ })
495+}
496+
497+/// Render a commit summary.
498+fn commit_summary_json(c: &git::CommitSummary) -> Value {
499+ json!({
500+ "sha": c.oid.as_str(),
501+ "summary": c.summary,
502+ "author": { "name": c.author.name, "email": c.author.email, "date": rfc3339(c.author.time_ms) },
503+ "parents": c.parents,
504+ })
505+}
506+
507+/// Render a full commit.
508+fn commit_detail_json(c: &git::CommitDetail) -> Value {
509+ json!({
510+ "sha": c.oid.as_str(),
511+ "message": c.message,
512+ "tree": c.tree.as_str(),
513+ "parents": c.parents.iter().map(git::Oid::as_str).collect::<Vec<_>>(),
514+ "author": { "name": c.author.name, "email": c.author.email, "date": rfc3339(c.author.time_ms) },
515+ "committer": { "name": c.committer.name, "email": c.committer.email, "date": rfc3339(c.committer.time_ms) },
516+ })
517+}
518+
519+/// The wire name for a tree entry kind.
520+fn entry_kind(kind: git::EntryKind) -> &'static str {
521+ match kind {
522+ git::EntryKind::Directory => "dir",
523+ git::EntryKind::File => "file",
524+ git::EntryKind::Symlink => "symlink",
525+ git::EntryKind::Submodule => "submodule",
526+ }
527+}
crates/api/src/lib.rs +254 −1
@@ -2,4 +2,257 @@
22 // License, v. 2.0. If a copy of the MPL was not distributed with this
33 // file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
5-//! The JSON API surface.
5+//! The JSON API served under `/api/v1`.
6+//!
7+//! Bearer-JWT authenticated (`auth`), scoped, and — like the web UI — it renders
8+//! `404` (never `403`) for a private resource the token cannot reach, so existence
9+//! never leaks. Errors share one shape:
10+//! `{"error": {"code": …, "message": …, "details": {}}}`. Timestamps are RFC 3339
11+//! strings on the wire even though they are epoch-ms at rest. The router is built
12+//! by [`router`] and nested at `/api/v1` by the web server.
13+
14+mod handlers;
15+
16+use std::sync::Arc;
17+use std::time::{SystemTime, UNIX_EPOCH};
18+
19+use axum::extract::FromRequestParts;
20+use axum::http::request::Parts;
21+use axum::http::{StatusCode, header};
22+use axum::response::{IntoResponse, Response};
23+use axum::routing::get;
24+use axum::{Json, Router};
25+use config::Config;
26+use model::User;
27+use store::Store;
28+
29+/// Shared state for the API router.
30+#[derive(Clone)]
31+pub struct ApiState {
32+ /// Database handle.
33+ pub store: Store,
34+ /// Effective configuration.
35+ pub config: Arc<Config>,
36+ /// The HS256 signing secret used to verify tokens.
37+ pub secret: Arc<String>,
38+}
39+
40+/// Build the API router, fully stated. Nest it at `/api/v1`.
41+pub fn router(store: Store, config: Arc<Config>, secret: String) -> Router {
42+ let state = ApiState {
43+ store,
44+ config,
45+ secret: Arc::new(secret),
46+ };
47+ Router::new()
48+ .route("/version", get(handlers::version))
49+ .route("/user", get(handlers::current_user))
50+ .route("/users", get(handlers::list_users))
51+ .route(
52+ "/repos",
53+ get(handlers::list_repos).post(handlers::create_repo),
54+ )
55+ .route(
56+ "/repos/{owner}/{*rest}",
57+ get(handlers::repo_get).delete(handlers::repo_delete),
58+ )
59+ .route("/groups/{owner}", get(handlers::list_groups))
60+ .route("/search", get(handlers::search))
61+ .fallback(handlers::not_found)
62+ .with_state(state)
63+}
64+
65+/// An API error, rendered as the shared JSON error shape.
66+#[derive(Debug)]
67+pub enum ApiError {
68+ /// 401 — missing or invalid bearer token.
69+ Unauthorized(&'static str),
70+ /// 403 — the token lacks the required scope.
71+ Forbidden(&'static str),
72+ /// 404 — missing, or a private resource the token cannot reach.
73+ NotFound,
74+ /// 400 — a malformed request.
75+ BadRequest(String),
76+ /// 409 — a uniqueness conflict.
77+ Conflict(String),
78+ /// 500 — an internal failure (detail logged, not shown).
79+ Internal(String),
80+}
81+
82+impl ApiError {
83+ /// Build an internal error, logging the detail.
84+ pub fn internal(detail: impl std::fmt::Display) -> Self {
85+ Self::Internal(detail.to_string())
86+ }
87+}
88+
89+impl From<store::StoreError> for ApiError {
90+ fn from(err: store::StoreError) -> Self {
91+ match err {
92+ store::StoreError::Conflict { entity, field } => {
93+ Self::Conflict(format!("{entity} already exists with that {field}"))
94+ }
95+ store::StoreError::Name(e) => Self::BadRequest(e.to_string()),
96+ other => Self::internal(other),
97+ }
98+ }
99+}
100+
101+impl From<git::GitError> for ApiError {
102+ fn from(err: git::GitError) -> Self {
103+ Self::internal(err)
104+ }
105+}
106+
107+impl IntoResponse for ApiError {
108+ fn into_response(self) -> Response {
109+ let (status, code, message) = match self {
110+ ApiError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, "unauthorized", m.to_string()),
111+ ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, "forbidden", m.to_string()),
112+ ApiError::NotFound => (StatusCode::NOT_FOUND, "not_found", "not found".to_string()),
113+ ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m),
114+ ApiError::Conflict(m) => (StatusCode::CONFLICT, "conflict", m),
115+ ApiError::Internal(detail) => {
116+ tracing::error!(error.detail = %detail, "api internal error");
117+ (
118+ StatusCode::INTERNAL_SERVER_ERROR,
119+ "internal",
120+ "internal error".to_string(),
121+ )
122+ }
123+ };
124+ let body = Json(serde_json::json!({
125+ "error": { "code": code, "message": message, "details": {} }
126+ }));
127+ let mut response = (status, body).into_response();
128+ if status == StatusCode::UNAUTHORIZED {
129+ response.headers_mut().insert(
130+ header::WWW_AUTHENTICATE,
131+ header::HeaderValue::from_static("Bearer"),
132+ );
133+ }
134+ response
135+ }
136+}
137+
138+/// A result alias for API handlers.
139+pub type ApiResult<T> = Result<T, ApiError>;
140+
141+/// The authenticated token identity, extracted from the `Authorization: Bearer`
142+/// header. Verifies the signature and expiry, that the `jti` row exists and is not
143+/// revoked, and that the user is enabled; bumps `last_used_at`.
144+pub struct ApiAuth {
145+ /// The token's user.
146+ pub user: User,
147+ /// The token's scopes.
148+ pub scopes: Vec<auth::Scope>,
149+}
150+
151+impl ApiAuth {
152+ /// Require a scope; `admin` grants everything.
153+ ///
154+ /// # Errors
155+ ///
156+ /// Returns [`ApiError::Forbidden`] if the scope is absent.
157+ pub fn require(&self, scope: auth::Scope) -> ApiResult<()> {
158+ if self.scopes.contains(&scope) || self.scopes.contains(&auth::Scope::Admin) {
159+ Ok(())
160+ } else {
161+ Err(ApiError::Forbidden("missing required scope"))
162+ }
163+ }
164+
165+ /// A [`auth::Viewer`] for permission checks.
166+ #[must_use]
167+ pub fn viewer(&self) -> auth::Viewer {
168+ auth::Viewer {
169+ id: self.user.id.clone(),
170+ is_admin: self.user.is_admin,
171+ }
172+ }
173+}
174+
175+impl FromRequestParts<ApiState> for ApiAuth {
176+ type Rejection = ApiError;
177+
178+ async fn from_request_parts(
179+ parts: &mut Parts,
180+ state: &ApiState,
181+ ) -> Result<Self, Self::Rejection> {
182+ let token = parts
183+ .headers
184+ .get(header::AUTHORIZATION)
185+ .and_then(|v| v.to_str().ok())
186+ .and_then(|v| v.strip_prefix("Bearer "))
187+ .ok_or(ApiError::Unauthorized("missing bearer token"))?;
188+
189+ let claims = auth::verify_jwt(&state.secret, token.trim(), now_secs())
190+ .map_err(|_| ApiError::Unauthorized("invalid or expired token"))?;
191+
192+ // Revocation is real only because we check the jti row here.
193+ let row = state
194+ .store
195+ .token_by_id(&claims.jti)
196+ .await?
197+ .ok_or(ApiError::Unauthorized("token revoked"))?;
198+ if row.revoked_at.is_some() {
199+ return Err(ApiError::Unauthorized("token revoked"));
200+ }
201+ let user = state
202+ .store
203+ .user_by_id(&claims.sub)
204+ .await?
205+ .ok_or(ApiError::Unauthorized("no such user"))?;
206+ if user.disabled_at.is_some() {
207+ return Err(ApiError::Unauthorized("account disabled"));
208+ }
209+ let _ = state.store.touch_token(&claims.jti).await;
210+
211+ Ok(Self {
212+ user,
213+ scopes: auth::parse_scopes(&claims.scopes).unwrap_or_default(),
214+ })
215+ }
216+}
217+
218+/// The current time in Unix seconds.
219+pub(crate) fn now_secs() -> i64 {
220+ SystemTime::now()
221+ .duration_since(UNIX_EPOCH)
222+ .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
223+}
224+
225+/// Format an epoch-millisecond timestamp as an RFC 3339 string, or `null` on
226+/// overflow.
227+pub(crate) fn rfc3339(ms: i64) -> serde_json::Value {
228+ let nanos = i128::from(ms) * 1_000_000;
229+ match time::OffsetDateTime::from_unix_timestamp_nanos(nanos) {
230+ Ok(dt) => dt
231+ .format(&time::format_description::well_known::Rfc3339)
232+ .map_or(serde_json::Value::Null, serde_json::Value::String),
233+ Err(_) => serde_json::Value::Null,
234+ }
235+}
236+
237+/// Format an optional timestamp.
238+pub(crate) fn rfc3339_opt(ms: Option<i64>) -> serde_json::Value {
239+ ms.map_or(serde_json::Value::Null, rfc3339)
240+}
241+
242+/// Run a blocking git read for a repo on the blocking pool.
243+pub(crate) async fn git_read<T, F>(state: &ApiState, repo_id: &str, f: F) -> ApiResult<T>
244+where
245+ F: FnOnce(&git::Repo) -> Result<T, git::GitError> + Send + 'static,
246+ T: Send + 'static,
247+{
248+ let repo_dir = state.config.storage.repo_dir.clone();
249+ let repo_id = repo_id.to_string();
250+ tokio::task::spawn_blocking(move || {
251+ let path = git::repo_path(&repo_dir, &repo_id)?;
252+ let repo = git::Repo::open_path(&path)?;
253+ f(&repo)
254+ })
255+ .await
256+ .map_err(ApiError::internal)?
257+ .map_err(ApiError::from)
258+}
crates/web/Cargo.toml +1 −0
@@ -16,6 +16,7 @@ model = { workspace = true }
1616 auth = { workspace = true }
1717 mail = { workspace = true }
1818 git = { workspace = true }
19+api = { workspace = true }
1920 highlight = { workspace = true }
2021 axum = { workspace = true }
2122 axum-extra = { workspace = true }
crates/web/src/lib.rs +8 −0
@@ -104,7 +104,15 @@ impl AppState {
104104 pub fn build_router(state: AppState) -> Router {
105105 let x_request_id = HeaderName::from_static("x-request-id");
106106
107+ // The JSON API is a self-contained, fully-stated router nested under /api/v1.
108+ let api = api::router(
109+ state.store.clone(),
110+ Arc::clone(&state.config),
111+ (*state.secret).clone(),
112+ );
113+
107114 Router::new()
115+ .nest_service("/api/v1", api)
108116 .route("/", get(pages::home))
109117 .route("/login", get(pages::login_form).post(pages::login_submit))
110118 .route("/logout", post(pages::logout))
docs/decisions.md +19 −0
@@ -65,6 +65,25 @@ file. There is no `server start` / `server stop` pair.
6565 **Rationale:** Foreground is correct for systemd and Docker, which own process
6666 lifecycle. This deviates from an earlier `server start`/`server stop` sketch.
6767
68+## 2026-07-25 — API nested into web via `nest_service`; web may depend on api
69+
70+**Decision:** The `/api/v1` JSON surface is a self-contained `Router` built by
71+`api::router(store, config, secret)` with its own state applied, and `web` nests it
72+with `.nest_service("/api/v1", …)`. `web` depends on `api`.
73+
74+**Alternatives:** Run the API on a separate port/server; have `api` depend on `web`
75+(forbidden — nothing may depend on `web`).
76+
77+**Rationale:** The dependency rule bars anything depending on `web`, but says
78+nothing against `web → api` (both are top-level). Nesting keeps one server, one
79+port, and one middleware stack. Because the nested router is fully stated
80+(`Router<()>`), `nest_service` mounts it as a `Service` and strips the `/api/v1`
81+prefix, so `api` defines routes relative to root and never learns `web`'s
82+`AppState`. Repo sub-resources are parsed by scanning `{*rest}` for the first
83+keyword segment (`branches`/`tags`/`commits`/`tree`/`raw`); a repo named exactly
84+after one of those would be misrouted — a documented MVP limitation. The API
85+search returns name/description matches only (content search stays in `web`).
86+
6887 ## 2026-07-25 — Search lives in `web`; `path:` is a substring for the MVP
6988
7089 **Decision:** Search (the qualifier parser, in-repo scan, and bounded global scan)