@@ -16,7 +15,7 @@ const App: Component = () => {
- {hostame}{" "}
+ {CONFIG.hostname}{" "}
dashboard
configure your knotty instance
diff --git a/app/src/lib/atproto.ts b/app/src/lib/atproto.ts
index 8eeb24d..c36c808 100644
--- a/app/src/lib/atproto.ts
+++ b/app/src/lib/atproto.ts
@@ -1,8 +1,9 @@
import { Client, simpleFetchHandler } from "@atcute/client";
import { XRPCProcedures } from "@atcute/lexicons/ambient";
import { OAuthUserAgent } from "@atcute/oauth-browser-client";
+import { CONFIG } from "./config";
-const KNOTTY_DID = `did:web:${import.meta.env.KNOT_HOSTNAME}`;
+const KNOTTY_DID = `did:web:${CONFIG.hostname}`;
export async function getServiceAuthToken(
agent: OAuthUserAgent,
@@ -31,7 +32,7 @@ export async function jwtHeaders(
export const newXrpcKnottyClient = () =>
new Client({
handler: simpleFetchHandler({
- service: `https://${import.meta.env.KNOT_HOSTNAME}`,
+ service: `https://${CONFIG.hostname}`,
}),
});
diff --git a/app/src/lib/config.ts b/app/src/lib/config.ts
new file mode 100644
index 0000000..4372587
--- /dev/null
+++ b/app/src/lib/config.ts
@@ -0,0 +1,17 @@
+import * as v from "valibot";
+
+const KnottyConfig = v.object({
+ hostname: v.string(),
+});
+
+export type KnottyConfig = v.InferOutput;
+
+const element = document.getElementById("knotty-config");
+if (!element) {
+ throw new Error("#knotty-config is missing from index.html");
+}
+
+export const CONFIG = v.parse(
+ KnottyConfig,
+ JSON.parse(element.textContent ?? ""),
+);
diff --git a/app/src/lib/oauth_metadata.ts b/app/src/lib/oauth_metadata.ts
index d4c8dbb..4dbc4cc 100644
--- a/app/src/lib/oauth_metadata.ts
+++ b/app/src/lib/oauth_metadata.ts
@@ -1,30 +1,18 @@
-import { Did } from "@atcute/lexicons";
+import { CONFIG } from "./config";
-export function buildOAuthClientMetadata(hostname: string, isDev: boolean) {
- const scope = `atproto repo:sh.tangled.repo rpc:dev.drawbu.knotty.create?aud=*`;
+// kept in sync by hand with OAUTH_SCOPE in crates/knotty-web/src/lib.rs, which
+// serves the client metadata document the PDS reads
+export const OAUTH_SCOPE =
+ "atproto repo:sh.tangled.repo rpc:dev.drawbu.knotty.create?aud=*";
- const client_uri = isDev ? "http://127.0.0.1:5173" : `https://${hostname}`;
- const client_id = isDev
- ? `http://localhost?redirect_uri=${encodeURIComponent(client_uri)}&scope=${encodeURIComponent(scope)}`
- : `https://${hostname}/oauth-client-metadata.json`;
- return {
- client_id,
- client_uri: client_uri,
- redirect_uris: [client_uri],
- scope,
- application_type: "web",
- token_endpoint_auth_method: "none",
- grant_types: ["authorization_code"],
- dpop_bound_access_tokens: true,
- };
-}
+const isDev = import.meta.env.DEV;
-const OAUTH_CLIENT_METADATA = buildOAuthClientMetadata(
- import.meta.env?.KNOT_HOSTNAME,
- import.meta.env?.DEV ?? false,
-);
+export const OAUTH_CLIENT_URI = isDev
+ ? "http://127.0.0.1:5173"
+ : `https://${CONFIG.hostname}`;
-export const OAUTH_SCOPE = OAUTH_CLIENT_METADATA.scope;
-export const OAUTH_CLIENT_ID = OAUTH_CLIENT_METADATA.client_id;
-export const OAUTH_CLIENT_URI = OAUTH_CLIENT_METADATA.client_uri;
-export const OAUTH_REDIRECT_URI = OAUTH_CLIENT_METADATA.redirect_uris[0];
+export const OAUTH_REDIRECT_URI = OAUTH_CLIENT_URI;
+
+export const OAUTH_CLIENT_ID = isDev
+ ? `http://localhost?redirect_uri=${encodeURIComponent(OAUTH_CLIENT_URI)}&scope=${encodeURIComponent(OAUTH_SCOPE)}`
+ : `${OAUTH_CLIENT_URI}/oauth-client-metadata.json`;
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 525e1d1..8785f90 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -1,31 +1,22 @@
import tailwindcss from "@tailwindcss/vite";
-import { mkdirSync, writeFileSync } from "node:fs";
import { defineConfig, type Plugin } from "vite";
import solidPlugin from "vite-plugin-solid";
import devtools from "solid-devtools/vite";
-import { buildOAuthClientMetadata } from "./src/lib/oauth_metadata.ts";
-function oauthMetadataPlugin(): Plugin {
+// dev mode only: the knot feeds the config on serve
+function knottyConfigPlugin(): Plugin {
return {
- name: "oauth-metadata",
- configResolved(config) {
- const metadata = buildOAuthClientMetadata(
- config.env.KNOT_HOSTNAME,
- config.command === "serve",
- );
-
- mkdirSync("public", { recursive: true });
- writeFileSync(
- "public/oauth-client-metadata.json",
- JSON.stringify(metadata),
- );
+ name: "knotty-config",
+ apply: "serve",
+ transformIndexHtml(html) {
+ const config = { hostname: process.env.KNOT_HOSTNAME };
+ return html.replace("__KNOTTY_CONFIG__", JSON.stringify(config));
},
};
}
export default defineConfig({
- plugins: [devtools(), solidPlugin(), tailwindcss(), oauthMetadataPlugin()],
- envPrefix: ["VITE_", "KNOT_"],
+ plugins: [devtools(), solidPlugin(), tailwindcss(), knottyConfigPlugin()],
server: {
// for OAuth, "localhost" hostname is not allowed (RFC 8252)
host: "127.0.0.1",
diff --git a/crates/knotty-knot/src/main.rs b/crates/knotty-knot/src/main.rs
index 2d1416c..41afd58 100644
--- a/crates/knotty-knot/src/main.rs
+++ b/crates/knotty-knot/src/main.rs
@@ -33,6 +33,10 @@ pub struct ServeArgs {
#[arg(long, env = "KNOT_LISTEN_ADDR", default_value = "0.0.0.0:5555")]
pub listen_addr: String,
+ /// Public hostname the knot is reachable at, backing its did:web
+ #[arg(long, env = "KNOT_HOSTNAME")]
+ pub hostname: String,
+
/// DID of the server owner
#[arg(long, env = "KNOT_OWNER")]
pub owner: String,
@@ -61,8 +65,6 @@ pub struct GenerateArgs {
pub overwrite: bool,
}
-static KNOT_HOSTNAME: &'static str = env!("KNOT_HOSTNAME");
-
#[tokio::main]
async fn main() -> miette::Result<()> {
tracing_subscriber::fmt::init();
@@ -80,7 +82,7 @@ async fn main() -> miette::Result<()> {
Commands::Serve(args) => {
let keypair = keypair::Keypair::load(&args.key_path)?;
- let did_str = format!("did:web:{}", KNOT_HOSTNAME);
+ let did_str = format!("did:web:{}", args.hostname);
let did = Did::new_owned(&did_str)
.into_diagnostic()
.wrap_err("building did:web with hostname")?;
@@ -106,7 +108,7 @@ async fn main() -> miette::Result<()> {
&did,
&keypair,
&owner,
- KNOT_HOSTNAME,
+ &args.hostname,
storage_path,
&args.plc_url,
db,
diff --git a/crates/knotty-knot/src/router.rs b/crates/knotty-knot/src/router.rs
index e6ff772..8b41998 100644
--- a/crates/knotty-knot/src/router.rs
+++ b/crates/knotty-knot/src/router.rs
@@ -7,9 +7,10 @@ use crate::state::KnotState;
pub fn router(state: KnotState) -> Router {
let did_doc = state.did_doc.clone();
+ let web = knotty_web::router(state.hostname);
crate::xrpc::router()
.with_state(state)
- .merge(knotty_web::router())
+ .merge(web)
.merge(did_web::did_web_router(did_doc))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
diff --git a/crates/knotty-web/Cargo.toml b/crates/knotty-web/Cargo.toml
index 0275309..a600d7b 100644
--- a/crates/knotty-web/Cargo.toml
+++ b/crates/knotty-web/Cargo.toml
@@ -8,4 +8,6 @@ description.workspace = true
axum.workspace = true
mime_guess.workspace = true
rust-embed.workspace = true
+serde.workspace = true
+serde_json.workspace = true
tracing.workspace = true
diff --git a/crates/knotty-web/src/lib.rs b/crates/knotty-web/src/lib.rs
index fcc273c..7eba3c2 100644
--- a/crates/knotty-web/src/lib.rs
+++ b/crates/knotty-web/src/lib.rs
@@ -1,39 +1,114 @@
+use std::borrow::Cow;
+
use axum::{
Router,
http::{StatusCode, header},
response::{IntoResponse, Response},
- routing::get,
+ routing::{MethodRouter, get},
};
use rust_embed::Embed;
+use serde::Serialize;
#[derive(Embed)]
#[folder = "../../app/dist"]
struct Asset;
-pub struct StaticFile(pub T);
+/// Serves an embedded asset, replacing any `CONFIG_PLACEHOLDER` it contains
+/// with the client configuration passed as the second field.
+pub struct StaticFile(pub T, pub &'static str);
impl IntoResponse for StaticFile
where
T: Into,
{
fn into_response(self) -> Response {
- let path = self.0.into();
+ let StaticFile(path, config) = self;
+ let path = path.into();
match Asset::get(path.as_str()) {
Some(content) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
- ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
+ let injected = inject(&content.data, config);
+ let body = injected.map_or(content.data, Cow::Owned);
+ ([(header::CONTENT_TYPE, mime.as_ref())], body).into_response()
}
None => (StatusCode::NOT_FOUND, "404 Not Found").into_response(),
}
}
}
-pub fn router() -> Router {
+fn inject(data: &[u8], config: &str) -> Option> {
+ let text = std::str::from_utf8(data).ok()?;
+ text.contains(CONFIG_PLACEHOLDER)
+ .then(|| text.replace(CONFIG_PLACEHOLDER, config).into_bytes())
+}
+
+const OAUTH_SCOPE: &str = "atproto repo:sh.tangled.repo rpc:dev.drawbu.knotty.create?aud=*";
+
+#[derive(Serialize)]
+struct OAuthClientMetadata {
+ client_id: String,
+ client_uri: String,
+ redirect_uris: [String; 1],
+ scope: &'static str,
+ application_type: &'static str,
+ token_endpoint_auth_method: &'static str,
+ grant_types: [&'static str; 1],
+ dpop_bound_access_tokens: bool,
+}
+
+impl OAuthClientMetadata {
+ fn new(hostname: &str) -> Self {
+ let client_uri = format!("https://{hostname}");
+ Self {
+ client_id: format!("{client_uri}/oauth-client-metadata.json"),
+ redirect_uris: [client_uri.clone()],
+ client_uri,
+ scope: OAUTH_SCOPE,
+ application_type: "web",
+ token_endpoint_auth_method: "none",
+ grant_types: ["authorization_code"],
+ dpop_bound_access_tokens: true,
+ }
+ }
+}
+
+const CONFIG_PLACEHOLDER: &str = "__KNOTTY_CONFIG__";
+
+#[derive(Serialize)]
+struct ClientConfig<'a> {
+ hostname: &'a str,
+}
+
+fn oauth_metadata_route(hostname: &str) -> MethodRouter {
+ let body = serde_json::to_string(&OAuthClientMetadata::new(hostname))
+ .expect("oauth client metadata serializes");
+
+ get(move || {
+ let body = body.clone();
+ async move { ([(header::CONTENT_TYPE, "application/json")], body) }
+ })
+}
+
+pub fn router(hostname: &str) -> Router {
+ let config: &'static str = serde_json::to_string(&ClientConfig { hostname })
+ .expect("client config serializes")
+ .leak();
+
Asset::iter()
.fold(Router::new(), |router, file| {
tracing::info!(file = %file, "serving static file");
- router.route(&format!("/{file}"), get(|| async { StaticFile(file) }))
+ router.route(
+ &format!("/{file}"),
+ get(move || async move { StaticFile(file, config) }),
+ )
})
- .route("/", get(|| async { StaticFile("index.html") }))
+ .route(
+ "/",
+ get(move || async move { StaticFile("index.html", config) }),
+ )
+ .route(
+ "/oauth-client-metadata.json",
+ oauth_metadata_route(hostname),
+ )
}