···4444 "dep:chrono",
4545 "dep:tracing-subscriber",
4646 "dep:rustls",
4747+ # OAuth (PM-27): confidential client, private cookie jar key, secret sources.
4848+ "dep:axum-extra",
4949+5050+ "dep:keyring",
5151+ "dep:base64",
4752]
4853[dependencies]
4954dioxus = { version = "0.7", features = ["router", "fullstack"] }
···9398] }
9499http = "1.4"
95100reqwest = { version = "0.12", default-features = false, features = ["json", "charset"] }
101101+102102+# PM-27 OAuth: private cookie-jar signing key and swappable secret sources
103103+# (OS keyring preferred; env override). reqwest is shared with the client crate.
104104+axum-extra = { version = "0.10", optional = true, features = ["cookie"] }
105105+106106+keyring = { version = "3", optional = true }
107107+base64 = { version = "0.22", optional = true }
961089710998110[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies]
+15-8
justfile
···2233set dotenv-load := false
4455+# Canonical SQLite database URL for the projection store and the compile-time
66+# `query!` macros. Scoping it here (rather than via .env) keeps `just` recipes
77+# hermetic — see `set dotenv-load := false` above.
88+db-url := 'sqlite:./data/polymodel.db'
99+510# Format Rust sources. Run clippy/check separately; clippy --fix can corrupt RSX.
611fix:
712 cargo fmt --all
···1116 cargo clippy -p polymodel --all-targets --features server -- -D warnings
1217 cargo clippy -p polymodel --target wasm32-unknown-unknown --features web -- -D warnings
1318# Compile checks across the whole workspace plus the app's server/wasm targets.
1414-# migrated SQLite database. Run `just migrate` first (or run `just sqlx-prepare`)
1515-# so the macros can resolve.
1919+# The server check resolves compile-time `query!` macros against the committed
2020+# `.sqlx` offline cache; regenerate it with `just sqlx-prepare` after changing
2121+# SQL or migrations.
1622check:
1723 cargo check --workspace
1824 cargo check -p polymodel --features server
1925 cargo check -p polymodel --target wasm32-unknown-unknown --features web
20262127# Create and migrate the SQLite projection database.
2222-# Required by the compile-time `query!` macros: run before the first server
2323-# check, and re-run after editing migrations.
2828+# Required when regenerating the `.sqlx` cache, and re-run after editing migrations.
2429migrate:
2530 mkdir -p ./data
2626- -cargo sqlx database create --database-url 'sqlite:./data/polymodel.db'
2727- cargo sqlx migrate run --source migrations --database-url 'sqlite:./data/polymodel.db'
3131+ -cargo sqlx database create --database-url '{{ db-url }}'
3232+ cargo sqlx migrate run --source migrations --database-url '{{ db-url }}'
28332934# Regenerate the committed `.sqlx` offline query cache after changing SQL.
3030-sqlx-prepare:
3131- cargo sqlx prepare -- -p polymodel --features server
3535+# Self-contained: migrates the local database first, then points `cargo sqlx
3636+# prepare` at it via DATABASE_URL so no manual env setup is required.
3737+sqlx-prepare: migrate
3838+ DATABASE_URL='{{ db-url }}' cargo sqlx prepare -- -p polymodel --features server
32393340# Run unit tests across the workspace.
3441test:
+32-7
migrations/001_projection_schema.sql
···150150 VALUES (new.rowid, new.name, new.summary, new.tags_text, new.instructions_text);
151151END;
152152153153--- OAuth session persistence (DB-backed ClientAuthStore; used in Task 3)
153153+-- OAuth session persistence (DB-backed ClientAuthStore for Jacquard 0.12 OAuth).
154154+--
155155+-- Each row mirrors the durable surfaces of `jacquard::oauth::session::ClientSessionData`:
156156+-- the structured columns (account_did, session_id, endpoints, timestamps) power
157157+-- key enumeration, by-DID/any selection, and deletes *without* deserializing the
158158+-- payload, while `payload` holds the canonical JSON serialization of the full
159159+-- `ClientSessionData` (token set, DPoP key + nonces, scopes). DPoP nonces are
160160+-- persisted here, not in an ad-hoc store; the in-process `SessionRegistry`
161161+-- mutex is concurrency-only and not durable.
154162CREATE TABLE oauth_sessions (
155155- did TEXT NOT NULL,
163163+ account_did TEXT NOT NULL,
156164 session_id TEXT NOT NULL,
157157- session_data BLOB NOT NULL,
165165+ host_url TEXT NOT NULL,
166166+ authserver_url TEXT NOT NULL,
167167+ token_endpoint TEXT NOT NULL,
168168+ revocation_endpoint TEXT,
158169 created_at INTEGER NOT NULL,
159170 updated_at INTEGER NOT NULL,
160160- PRIMARY KEY (did, session_id)
171171+ payload TEXT NOT NULL,
172172+ PRIMARY KEY (account_did, session_id)
161173);
174174+CREATE INDEX idx_oauth_sessions_did ON oauth_sessions(account_did);
162175176176+-- Pending OAuth authorization requests (PAR `state` round-trip).
177177+-- `payload` holds the canonical JSON serialization of
178178+-- `jacquard::oauth::session::AuthRequestData` (PKCE verifier, DPoP key + nonce,
179179+-- request_uri). `account_did` is nullable because the initiating identifier is
180180+-- not always known when the request is created.
163181CREATE TABLE oauth_auth_requests (
164164- state TEXT PRIMARY KEY,
165165- request_data BLOB NOT NULL,
166166- created_at INTEGER NOT NULL
182182+ state TEXT NOT NULL,
183183+ authserver_url TEXT NOT NULL,
184184+ account_did TEXT,
185185+ request_uri TEXT NOT NULL,
186186+ token_endpoint TEXT NOT NULL,
187187+ revocation_endpoint TEXT,
188188+ created_at INTEGER NOT NULL,
189189+ payload TEXT NOT NULL,
190190+ PRIMARY KEY (state)
167191);
192192+CREATE INDEX idx_oauth_auth_req_did ON oauth_auth_requests(account_did);
···2121#[cfg(test)]
2222mod tests;
23232424-use std::sync::Arc;
2525-2624use axum::Router;
2725use jacquard_axum::IntoRouter;
2826use jacquard_common::xrpc::XrpcEndpoint;
···39374038/// Build the appview XRPC router with the given state applied.
4139///
4242-/// The returned `Router<()>` is merged with the Dioxus SSR router in `main`.
4343-pub fn router(state: Arc<AppState>) -> Router {
4040+/// Returns a state-bound `Router<AppState>`; merged with the OAuth routes and the Dioxus SSR router in `main`, where `.with_state` is applied once.
4141+pub fn router() -> Router<AppState> {
4442 Router::new()
4543 .merge(GetThingRequest::into_router(library::get_thing))
4644 .merge(GetModelRequest::into_router(library::get_model))
···5856 GetSessionRequest::PATH,
5957 axum::routing::get(actor::get_session),
6058 )
6161- .with_state(state)
6259}
+50-6
src/appview/state.rs
···66//! authorities to DIDs for `getProfile`; `bsky` fetches the
77//! `app.bsky.actor.defs#profileViewDetailed` union variant from the public
88//! AppView for actors with no polymodel profile.
99+//!
1010+//! PM-27 adds the confidential OAuth client, single-origin web config, and
1111+//! private cookie-jar key. `AppState` is the Axum router state type (local, so
1212+//! the [`FromRef`] impls for [`OAuthWebConfig`] and the cookie [`Key`] satisfy
1313+//! the orphan rule); it is cheaply `Clone` (pool/resolver/oauth are
1414+//! `Arc`-backed).
9151616+use std::sync::Arc;
1717+1818+use axum::extract::FromRef;
1919+use axum_extra::extract::cookie::Key;
1020use jacquard::client::BasicClient;
1121use jacquard::identity::PublicResolver;
2222+use jacquard::oauth::client::OAuthClient;
2323+use jacquard_axum::oauth::{OAuthWebConfig, OAuthWebState};
1224use sqlx::SqlitePool;
13251414-/// State shared by all XRPC read handlers.
2626+use crate::oauth::{OAuthBootstrap, SqliteAuthStore};
2727+2828+/// State shared by all XRPC read handlers and the OAuth routes.
2929+#[derive(Clone)]
1530pub struct AppState {
1631 /// SQLite projection (PM-25): content, social counts, profiles, identities.
1732 pub pool: SqlitePool,
···1934 pub resolver: PublicResolver,
2035 /// Unauthenticated client aimed at the public Bluesky AppView
2136 /// (`https://public.api.bsky.app`) for the bluesky-only profile variant.
2222- pub bsky: BasicClient,
3737+ /// `Arc`-wrapped because `BasicClient` (`Agent`) is not `Clone`.
3838+ pub bsky: Arc<BasicClient>,
3939+ /// Confidential OAuth client (PM-27) over the SQLite auth store.
4040+ pub oauth: Arc<OAuthClient<PublicResolver, SqliteAuthStore>>,
4141+ /// Single-origin OAuth web config (cookie name + route paths).
4242+ pub oauth_config: OAuthWebConfig,
4343+ /// Signing key for the private `jacquard_oauth_session` cookie jar.
4444+ pub cookie_key: Key,
2345}
24462547impl AppState {
2626- /// Construct read state from the shared SQLite pool. The resolver and bsky
2727- /// client are unauthenticated/public and need no configuration.
2828- pub fn new(pool: SqlitePool) -> Self {
4848+ /// Construct read + OAuth state from the shared SQLite pool and the
4949+ /// startup-built OAuth bootstrap (keyset, config, cookie key).
5050+ pub fn new(pool: SqlitePool, bootstrap: OAuthBootstrap) -> Self {
2951 Self {
3052 pool,
3153 resolver: jacquard::identity::slingshot_resolver_default(),
3232- bsky: jacquard::client::BasicClient::unauthenticated(),
5454+ bsky: Arc::new(jacquard::client::BasicClient::unauthenticated()),
5555+ oauth: Arc::new(bootstrap.client),
5656+ oauth_config: bootstrap.config,
5757+ cookie_key: bootstrap.cookie_key,
3358 }
3459 }
3560}
6161+6262+/// Expose the OAuth client to `jacquard_axum::oauth` routes/extractors.
6363+impl OAuthWebState<PublicResolver, SqliteAuthStore> for AppState {
6464+ fn oauth_client(&self) -> &OAuthClient<PublicResolver, SqliteAuthStore> {
6565+ self.oauth.as_ref()
6666+ }
6767+}
6868+6969+impl FromRef<AppState> for OAuthWebConfig {
7070+ fn from_ref(state: &AppState) -> Self {
7171+ state.oauth_config.clone()
7272+ }
7373+}
7474+7575+impl FromRef<AppState> for Key {
7676+ fn from_ref(state: &AppState) -> Self {
7777+ state.cookie_key.clone()
7878+ }
7979+}
+26-19
src/appview/tests.rs
···8899#![cfg(test)]
10101111-use std::sync::Arc;
1212-1311use axum::body::Body;
1412use axum::http::{Request, StatusCode};
1513use jacquard_common::types::string::Did;
···4240 pool
4341}
44424545-async fn state() -> Arc<AppState> {
4646- Arc::new(AppState::new(pool().await))
4343+async fn state() -> AppState {
4444+ let pool = pool().await;
4545+ let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost"))
4646+ .expect("ephemeral OAuth bootstrap for tests");
4747+ AppState::new(pool, bootstrap)
4748}
48494949-// Seeding uses runtime queries so the committed .sqlx cache stays focused on
5050-// production SQL.
5050+// Seeding uses runtime queries so the committed .sqlx cache stays focused on production SQL.
51515252fn blob(cid: &str) -> serde_json::Value {
5353 json!({
···317317 seed_thing(&state.pool, DID_A, "3yyyyyyyyyyyy", "t2", &[], 0).await;
318318 seed_thing(&state.pool, DID_A, "3xxxxxxxxxxxx", "t1", &[], 0).await;
319319320320- let page1 = views::author_things(&state, DID_A, 2, None).await.unwrap();
320320+ let page1 = views::author_things(&state, DID_A, 2, None, None)
321321+ .await
322322+ .unwrap();
321323 assert_eq!(page1.items.len(), 2);
322324 assert_eq!(page1.items[0].thing.name.as_str(), "t3");
323325 let cursor = page1.cursor.expect("cursor present when more remain");
324326325325- let page2 = views::author_things(&state, DID_A, 2, Some(cursor.as_ref()))
327327+ let page2 = views::author_things(&state, DID_A, 2, Some(cursor.as_ref()), None)
326328 .await
327329 .unwrap();
328330 assert_eq!(page2.items.len(), 1);
···339341 seed_thing(&state.pool, DID_A, "3aaaaaaaaaaaa", "a", &[], 5).await;
340342 seed_thing(&state.pool, DID_A, "3bbbbbbbbbbbb", "b", &[], 5).await;
341343342342- let feed = views::feed_hot(&state, 10, None).await.unwrap();
344344+ let feed = views::feed_hot(&state, 10, None, None).await.unwrap();
343345 assert_eq!(feed.items.len(), 2);
344344- let feed_again = views::feed_hot(&state, 10, None).await.unwrap();
346346+ let feed_again = views::feed_hot(&state, 10, None, None).await.unwrap();
345347 assert_eq!(
346348 feed.items[0].thing.uri.as_ref(),
347349 feed_again.items[0].thing.uri.as_ref()
···357359 seed_thing(&state.pool, DID_A, "r1", "Calibration Cube", &[], 0).await;
358360 seed_thing(&state.pool, DID_A, "r2", "Friendly Sphere", &[], 0).await;
359361360360- let feed = views::search_things(&state, "cube", 10, None)
362362+ let feed = views::search_things(&state, "cube", 10, None, None)
361363 .await
362364 .unwrap();
363365 assert_eq!(feed.items.len(), 1);
···367369#[tokio::test]
368370async fn malformed_ranked_cursor_is_rejected() {
369371 let state = state().await;
370370- let err = views::search_things(&state, "x", 10, Some("not-a-number"))
372372+ let err = views::search_things(&state, "x", 10, Some("not-a-number"), None)
371373 .await
372374 .unwrap_err();
373375 assert!(matches!(err, AppError::InvalidRequest(_)));
···387389 .bind(DID_A).bind("i2").bind(list).bind(non_thing).bind(2)
388390 .execute(&state.pool).await.unwrap();
389391390390- let (items, _cursor) = views::list_things(&state, list, 10, None).await.unwrap();
392392+ let (items, _cursor) = views::list_things(&state, list, 10, None, None)
393393+ .await
394394+ .unwrap();
391395 assert_eq!(items.len(), 1, "non-thing list item must be filtered out");
392396 assert_eq!(items[0].uri.as_ref(), thing);
393397}
···457461#[tokio::test]
458462async fn get_session_route_returns_unauthenticated_marker() {
459463 let state = state().await;
460460- let app = super::router(state);
464464+ let app = super::router().with_state(state);
461465 let resp = app
462466 .oneshot(
463467 Request::builder()
···475479#[tokio::test]
476480async fn get_thing_route_decodes_query_and_404s_when_missing() {
477481 let state = state().await;
478478- let app = super::router(state);
482482+ let app = super::router().with_state(state);
479483 let resp = app
480484 .oneshot(
481485 Request::builder()
···492496async fn negative_offset_cursor_is_rejected() {
493497 let state = state().await;
494498 let errs = [
495495- views::search_things(&state, "x", 10, Some("-1"))
499499+ views::search_things(&state, "x", 10, Some("-1"), None)
496500 .await
497501 .unwrap_err(),
498498- views::feed_hot(&state, 10, Some("-5")).await.unwrap_err(),
502502+ views::feed_hot(&state, 10, Some("-5"), None)
503503+ .await
504504+ .unwrap_err(),
499505 views::list_things(
500506 &state,
501507 "at://did:plc:l/space.polymodel.graph.list/self",
502508 10,
503509 Some("-1"),
510510+ None,
504511 )
505512 .await
506513 .unwrap_err(),
···521528 seed_thing(&state.pool, DID_A, "3yyyyyyyyyyyy", "t2", &[], 0).await;
522529 seed_thing(&state.pool, DID_A, "3xxxxxxxxxxxx", "t1", &[], 0).await;
523530524524- let page1 = views::feed_recent(&state, 2, None).await.unwrap();
531531+ let page1 = views::feed_recent(&state, 2, None, None).await.unwrap();
525532 assert_eq!(page1.items.len(), 2);
526533 assert_eq!(page1.items[0].thing.name.as_str(), "t3");
527534 let cursor = page1.cursor.expect("more remain");
528528- let page2 = views::feed_recent(&state, 2, Some(cursor.as_ref()))
535535+ let page2 = views::feed_recent(&state, 2, Some(cursor.as_ref()), None)
529536 .await
530537 .unwrap();
531538 assert_eq!(page2.items.len(), 1);
···11//! Server runtime configuration.
22//!
33-//! Configuration that varies per deployment (e.g. `DATABASE_URL`) and is only
44-//! needed by the server binary. These values are read from the process
55-//! environment at startup, not baked into the WASM bundle.
33+//! Configuration that varies per deployment (e.g. `DATABASE_URL`,
44+//! `POLYMODEL_BASE_URL`) and is only needed by the server binary. These values
55+//! are read from the process environment at startup, not baked into the WASM
66+//! bundle.
67//!
78//! The server binary calls `dotenvy::dotenv()` in `main` before
89//! [`ServerConfig::load`]. Hydrant reads its own `HYDRANT_*` variables via its
···1617pub struct ServerConfig {
1718 /// sqlx connect URL, e.g. `sqlite:./data/polymodel.db`.
1819 pub database_url: String,
2020+ /// Public origin of this server, e.g. `https://polymodel.example.com`.
2121+ ///
2222+ /// The OAuth `client_id` is `{base_url}/oauth-client-metadata.json` and the
2323+ /// redirect URI is `{base_url}/oauth/callback` (PM-27). When unset, bootstrap
2424+ /// defaults the OAuth client origin to `https://polymodel.space`, a functional
2525+ /// hosted client; set `POLYMODEL_BASE_URL` for local or alternate origins.
2626+ pub base_url: Option<String>,
1927}
20282129impl ServerConfig {
···2634 pub fn load() -> Self {
2735 let database_url =
2836 env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:./data/polymodel.db".to_string());
2929- Self { database_url }
3737+ let base_url = env::var("POLYMODEL_BASE_URL")
3838+ .ok()
3939+ .map(|s| s.trim().to_owned())
4040+ .filter(|s| !s.is_empty());
4141+ Self {
4242+ database_url,
4343+ base_url,
4444+ }
3045 }
3146}