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"
198[[package]]198[[package]]
199name = "api"199name = "api"
200version = "0.1.0"200version = "0.1.0"
201dependencies = [
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
202[[package]]215[[package]]
203name = "argon2"216name = "argon2"
@@ -5570,6 +5583,7 @@ name = "web"
5570version = "0.1.0"5583version = "0.1.0"
5571dependencies = [5584dependencies = [
5572 "ammonia",5585 "ammonia",
5586 "api",
5573 "auth",5587 "auth",
5574 "axum",5588 "axum",
5575 "axum-extra",5589 "axum-extra",
crates/api/Cargo.toml +11 −0
@@ -10,6 +10,17 @@ repository.workspace = true
10publish.workspace = true10publish.workspace = true
1111
12[dependencies]12[dependencies]
13config = { workspace = true }
14store = { workspace = true }
15model = { workspace = true }
16git = { workspace = true }
17auth = { workspace = true }
18axum = { workspace = true }
19serde = { workspace = true }
20serde_json = { workspace = true }
21time = { workspace = true }
22tokio = { workspace = true }
23tracing = { workspace = true }
1324
14[lints]25[lints]
15workspace = true26workspace = 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
7use std::path::Path as FsPath;
8
9use auth::{Access, Scope};
10use axum::Json;
11use axum::extract::{Path, Query, State};
12use axum::http::{StatusCode, header};
13use axum::response::{IntoResponse, Response};
14use model::{Repo, User};
15use serde::Deserialize;
16use serde_json::{Value, json};
17
18use crate::{ApiAuth, ApiError, ApiResult, ApiState, git_read, rfc3339, rfc3339_opt};
19
20/// Pagination + ref query parameters.
21#[derive(Debug, Default, Deserialize)]
22pub 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
33impl 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`.
43pub 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.
51pub async fn current_user(auth: ApiAuth) -> ApiResult<Json<Value>> {
52 Ok(Json(user_json(&auth.user)))
53}
54
55/// `GET /users` — admin only.
56pub 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.
71pub 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)]
95pub 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`.
107pub 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.
161enum 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).
174fn 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.
203pub 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=`.
307pub 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}`.
339pub 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).
359pub 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.
398pub 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.
405async 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.
430async 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.
445async 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.
456fn 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).
468fn 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.
480fn 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.
498fn 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.
508fn 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.
520fn 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 @@
2// License, v. 2.0. If a copy of the MPL was not distributed with this2// 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/.3// 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
14mod handlers;
15
16use std::sync::Arc;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use axum::extract::FromRequestParts;
20use axum::http::request::Parts;
21use axum::http::{StatusCode, header};
22use axum::response::{IntoResponse, Response};
23use axum::routing::get;
24use axum::{Json, Router};
25use config::Config;
26use model::User;
27use store::Store;
28
29/// Shared state for the API router.
30#[derive(Clone)]
31pub 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`.
41pub 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)]
67pub 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
82impl 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
89impl 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
101impl From<git::GitError> for ApiError {
102 fn from(err: git::GitError) -> Self {
103 Self::internal(err)
104 }
105}
106
107impl 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.
139pub 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`.
144pub struct ApiAuth {
145 /// The token's user.
146 pub user: User,
147 /// The token's scopes.
148 pub scopes: Vec<auth::Scope>,
149}
150
151impl 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
175impl 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.
219pub(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.
227pub(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.
238pub(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.
243pub(crate) async fn git_read<T, F>(state: &ApiState, repo_id: &str, f: F) -> ApiResult<T>
244where
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 }
16auth = { workspace = true }16auth = { workspace = true }
17mail = { workspace = true }17mail = { workspace = true }
18git = { workspace = true }18git = { workspace = true }
19api = { workspace = true }
19highlight = { workspace = true }20highlight = { workspace = true }
20axum = { workspace = true }21axum = { workspace = true }
21axum-extra = { workspace = true }22axum-extra = { workspace = true }
crates/web/src/lib.rs +8 −0
@@ -104,7 +104,15 @@ impl AppState {
104pub fn build_router(state: AppState) -> Router {104pub fn build_router(state: AppState) -> Router {
105 let x_request_id = HeaderName::from_static("x-request-id");105 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
107 Router::new()114 Router::new()
115 .nest_service("/api/v1", api)
108 .route("/", get(pages::home))116 .route("/", get(pages::home))
109 .route("/login", get(pages::login_form).post(pages::login_submit))117 .route("/login", get(pages::login_form).post(pages::login_submit))
110 .route("/logout", post(pages::logout))118 .route("/logout", post(pages::logout))
docs/decisions.md +19 −0
@@ -65,6 +65,25 @@ file. There is no `server start` / `server stop` pair.
65**Rationale:** Foreground is correct for systemd and Docker, which own process65**Rationale:** Foreground is correct for systemd and Docker, which own process
66lifecycle. This deviates from an earlier `server start`/`server stop` sketch.66lifecycle. 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
72with `.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
78nothing against `web → api` (both are top-level). Nesting keeps one server, one
79port, 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`
81prefix, 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
83keyword segment (`branches`/`tags`/`commits`/`tree`/`raw`); a repo named exactly
84after one of those would be misrouted — a documented MVP limitation. The API
85search returns name/description matches only (content search stays in `web`).
86
68## 2026-07-25 — Search lives in `web`; `path:` is a substring for the MVP87## 2026-07-25 — Search lives in `web`; `path:` is a substring for the MVP
6988
70**Decision:** Search (the qualifier parser, in-repo scan, and bounded global scan)89**Decision:** Search (the qualifier parser, in-repo scan, and bounded global scan)