571572f5 Andy McCall 2 days ago ↶
1
2
3
4
5
6
use crate :: protocol:: { self , Headers, Reply} ;
7
use crate :: tools:: Ctx;
8
use axum:: body:: Bytes;
9
use axum:: extract:: connect_info:: { ConnectInfo, Connected} ;
10
use axum:: extract:: State;
11
use axum:: http:: { HeaderMap, StatusCode} ;
12
use axum:: response:: sse:: { Event as SseEvent, KeepAlive, Sse} ;
13
use axum:: response:: { IntoResponse, Response} ;
14
use axum:: routing:: post;
15
use axum:: Router;
16
use futures:: stream:: { self , StreamExt} ;
17
use serde_json:: { json, Value as Json} ;
18
use spectra_core:: authn:: Caller;
19
use spectra_core:: daemon:: { Daemon, Peer, WireEvent} ;
20
use spectra_core:: principals:: Principal;
21
use spectra_core:: proof:: Proof;
22
use std:: convert:: Infallible;
23
use std:: sync:: Arc;
24
use std:: time:: Duration;
25
26
27
# [ derive ( Clone, Copy, Debug ) ]
28
pub struct PeerInfo ( pub Option< Peer> ) ;
29
30
impl Connected< axum:: serve:: IncomingStream< '_ , tokio:: net:: UnixListener> > for PeerInfo {
31
fn connect_info ( stream : axum:: serve:: IncomingStream< '_ , tokio:: net:: UnixListener> ) -> Self {
32
let cred = stream.io ( ) .peer_cred ( ) .ok ( ) ;
33
PeerInfo( cred.map ( | c | Peer { uid: c.uid ( ) , pid: c.pid ( ) } ) )
34
}
35
}
36
37
impl Connected< axum:: serve:: IncomingStream< '_ , tokio:: net:: TcpListener> > for PeerInfo {
38
fn connect_info ( _ : axum:: serve:: IncomingStream< '_ , tokio:: net:: TcpListener> ) -> Self {
39
PeerInfo( None )
40
}
41
}
42
b576d05c Andy McCall 1 day ago ↶
43
impl Connected< axum:: serve:: IncomingStream< '_ , crate:: tls:: TlsListener> > for PeerInfo {
44
fn connect_info ( _ : axum:: serve:: IncomingStream< '_ , crate:: tls:: TlsListener> ) -> Self {
45
PeerInfo( None )
46
}
47
}
48
571572f5 Andy McCall 2 days ago ↶
49
50
# [ derive ( Clone ) ]
51
pub struct AppState {
52
pub daemon : Arc< Daemon> ,
53
pub origins : Arc< Vec< String> > ,
54
}
55
56
57
pub fn router ( state : AppState) -> Router {
58
Router:: new( )
59
.route ( " /mcp" , post ( mcp) .get ( not_allowed) .delete ( not_allowed) )
60
.route ( " /api/v1/ledger/events" , post ( owner_events) )
b576d05c Andy McCall 1 day ago ↶
61
.route ( " /api/v1/instance" , axum:: routing:: get( instance) )
77df591d Andy McCall 1 day ago ↶
62
.route ( " /api/v1/claim" , post ( claim) )
571572f5 Andy McCall 2 days ago ↶
63
.with_state ( state)
b576d05c Andy McCall 1 day ago ↶
64
}
65
66
67
68
69
async fn instance ( State( state ) : State< AppState> ) -> Response {
70
let ( instance, owner) = {
71
let p = state.daemon.principals.read ( ) .await;
72
( p.instance ( ) .cloned ( ) , p.owner ( ) .map ( | o | o.hash ( ) .to_hex ( ) ) )
73
} ;
eca5af92 Andy McCall 1 day ago ↶
74
let owner_missing = owner.is_none ( ) ;
b576d05c Andy McCall 1 day ago ↶
75
let Some ( instance) = instance else {
76
return ( StatusCode:: SERVICE_UNAVAILABLE , axum:: Json( json! ( { " title" : " this Spectra has no instance key" } ) ) ) .into_response ( ) ;
77
} ;
78
let tls = match crate :: instance:: latest_tls( & state.daemon.ledger) .await {
79
Ok ( Some ( e) ) => json! ( { " payload" : e.json ( ) , " proof" : e.auth.proof.as_ref ( ) .map ( hex:: encode) } ) ,
80
_ => Json:: Null,
81
} ;
82
axum:: Json( json! ( {
83
" name" : instance.name,
84
" instance" : spectra_core:: principals:: identity_payload( & instance) ,
85
" owner" : owner,
eca5af92 Andy McCall 1 day ago ↶
86
87
" named_owner" : if owner_missing { state.daemon.named_owner ( ) } else { None } ,
b576d05c Andy McCall 1 day ago ↶
88
" tls" : tls,
89
} ) )
90
.into_response ( )
571572f5 Andy McCall 2 days ago ↶
91
}
92
77df591d Andy McCall 1 day ago ↶
93
94
95
96
async fn claim ( State( state ) : State< AppState> , body : Bytes) -> Response {
eca5af92 Andy McCall 1 day ago ↶
97
let events: Vec< WireEvent> = match serde_json:: from_slice( & body) {
77df591d Andy McCall 1 day ago ↶
98
Ok ( v) => v,
eca5af92 Andy McCall 1 day ago ↶
99
Err ( e) => return ( StatusCode:: BAD_REQUEST , axum:: Json( json! ( { " title" : format! ( " not a list of events: {e} " ) } ) ) ) .into_response ( ) ,
77df591d Andy McCall 1 day ago ↶
100
} ;
eca5af92 Andy McCall 1 day ago ↶
101
match state.daemon.claim ( events) .await {
102
Ok ( seqs) => axum:: Json( json! ( { " seqs" : seqs } ) ) .into_response ( ) ,
77df591d Andy McCall 1 day ago ↶
103
Err ( e) => ( StatusCode:: FORBIDDEN , axum:: Json( json! ( { " title" : format! ( " {e:#} " ) } ) ) ) .into_response ( ) ,
104
}
105
}
106
571572f5 Andy McCall 2 days ago ↶
107
async fn not_allowed ( ) -> StatusCode {
108
StatusCode:: METHOD_NOT_ALLOWED
109
}
110
111
112
pub fn proof_from ( headers : & HeaderMap, method_path : & str , body : & [u8 ]) -> Option< Proof> {
113
let get = | k : & str | headers.get ( k) .and_then ( | v | v.to_str ( ) .ok ( ) ) ;
114
let key = get ( " spectra-key" ) ? ;
115
let time_ms = get ( " spectra-time" ) ? .parse ( ) .ok ( ) ? ;
116
let nonce: [ u8 ; 16 ] = hex:: decode( get ( " spectra-nonce" ) ? ) .ok ( ) ? .try_into ( ) .ok ( ) ? ;
117
let sig_bytes = hex:: decode( get ( " spectra-signature" ) ? ) .ok ( ) ? ;
98a0fb9f Andy McCall 1 day ago ↶
118
let sig = spectra_core:: cbor:: decode( & sig_bytes) .ok ( ) .and_then ( | v | spectra_core:: identity:: Signature:: from_value( & v) .ok ( ) ) ? ;
571572f5 Andy McCall 2 days ago ↶
119
if sig.key.to_hex ( ) ! = key {
120
return None ;
121
}
122
Some ( Proof { sig, method: method_path.into ( ) , body_hash: * spectra_core:: event:: hash_body_plain( body) , time_ms, nonce } )
123
}
124
125
126
pub fn origin_ok ( headers : & HeaderMap, allowed : & [String]) -> bool {
127
match headers.get ( " origin" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) {
128
None => true ,
129
Some ( o) => allowed.iter ( ) .any ( | a | a = = o ) ,
130
}
131
}
132
77df591d Andy McCall 1 day ago ↶
133
134
pub async fn authenticate_why (
571572f5 Andy McCall 2 days ago ↶
135
state : & AppState,
136
peer : PeerInfo,
137
headers : & HeaderMap,
138
method_path : & str ,
139
body : & [u8 ],
77df591d Andy McCall 1 day ago ↶
140
) -> Result< Caller, ( StatusCode, String) > {
571572f5 Andy McCall 2 days ago ↶
141
if ! origin_ok ( headers, & state.origins) {
77df591d Andy McCall 1 day ago ↶
142
return Err ( ( StatusCode:: FORBIDDEN , " this origin may not call Spectra" .into ( ) ) ) ;
571572f5 Andy McCall 2 days ago ↶
143
}
144
let proof = proof_from ( headers, method_path, body) ;
145
state.daemon.authenticate ( proof.as_ref ( ) , method_path, body, peer.0 ) .await.map_err ( | why | {
146
tracing:: info! ( % why, " refused a request" ) ;
147
148
77df591d Andy McCall 1 day ago ↶
149
( StatusCode:: UNAUTHORIZED , why.to_string ( ) )
150
} )
151
}
152
153
154
pub async fn authenticate (
155
state : & AppState,
156
peer : PeerInfo,
157
headers : & HeaderMap,
158
method_path : & str ,
159
body : & [u8 ],
160
) -> Result< Caller, Response> {
161
authenticate_why ( state, peer, headers, method_path, body) .await.map_err ( | ( status , why ) | {
162
let ( code, msg) = if status = = StatusCode:: FORBIDDEN {
163
( protocol:: INVALID_REQUEST , why)
164
} else {
165
( protocol:: UNAUTHENTICATED , format! ( " not authenticated: {why} " ) )
166
} ;
167
( status, axum:: Json( protocol:: error( & Json:: Null, code, & msg) ) ) .into_response ( )
571572f5 Andy McCall 2 days ago ↶
168
} )
169
}
170
171
async fn mcp ( State( state ) : State< AppState> , ConnectInfo( peer ) : ConnectInfo< PeerInfo> , headers : HeaderMap, body : Bytes) -> Response {
172
let caller = match authenticate ( & state, peer, & headers, " POST /mcp" , & body) .await {
173
Ok ( c) => c,
174
Err ( r) => return r,
175
} ;
176
let req: Json = match serde_json:: from_slice( & body) {
177
Ok ( v) => v,
178
Err ( e) => {
179
return ( StatusCode:: BAD_REQUEST , axum:: Json( protocol:: error( & Json:: Null, protocol:: PARSE_ERROR , & format! ( " not JSON: {e} " ) ) ) )
180
.into_response ( )
181
}
182
} ;
183
let new_session = req.pointer ( " /params/arguments/new_session" ) .and_then ( Json:: as_bool) .unwrap_or ( false )
184
& & req.pointer ( " /params/name" ) .and_then ( Json:: as_str) = = Some ( " brief" ) ;
185
let session = state.daemon.touch ( & caller.principal, new_session) .await;
186
let h = Headers {
187
version: headers.get ( " mcp-protocol-version" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) ,
188
method: headers.get ( " mcp-method" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) ,
189
name: headers.get ( " mcp-name" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) ,
190
} ;
191
let version = h.version.unwrap_or ( " 2025-03-26" ) .to_string ( ) ;
192
let ctx = Ctx { daemon: state.daemon.clone ( ) , caller, session } ;
193
let meta = meta ( & ctx) .await;
194
match protocol:: handle( & ctx, & h, req, meta.clone ( ) ) .await {
195
Reply:: Json( status, v) => ( StatusCode:: from_u16( status) .unwrap_or ( StatusCode:: OK ) , axum:: Json( v) ) .into_response ( ) ,
196
Reply:: Accepted => StatusCode:: ACCEPTED .into_response ( ) ,
197
Reply:: Stream( id, tool, args) => {
198
let fut = async move {
037c45d8 Andy McCall 2 days ago ↶
199
let out = crate :: tools:: invoke( & ctx, tool, args) .await;
571572f5 Andy McCall 2 days ago ↶
200
let meta = crate :: http:: meta( & ctx) .await;
201
let msg = protocol:: stream_result( & id, & version, & out, meta) ;
202
Ok : : < _ , Infallible> ( SseEvent:: default( ) .event ( " message" ) .data ( msg.to_string ( ) ) )
203
} ;
204
sse ( stream:: once( fut) .boxed ( ) )
205
}
206
Reply:: Listen( id, sub) => {
207
let ack = json! ( { " jsonrpc" : " 2.0" , " method" : " notifications/subscriptions/acknowledged" , " params" : { " _meta" : { " io.modelcontextprotocol/subscriptionId" : sub } } } ) ;
208
let _ = id;
209
let first = stream:: once( async move { Ok : : < _ , Infallible> ( SseEvent:: default( ) .event ( " message" ) .data ( ack.to_string ( ) ) ) } ) ;
210
211
212
sse ( first.chain ( stream:: pending( ) ) .boxed ( ) )
213
}
214
}
215
}
216
217
fn sse ( s : futures:: stream:: BoxStream< 'static , Result< SseEvent, Infallible> > ) -> Response {
218
let mut r = Sse:: new( s) .keep_alive ( KeepAlive:: new( ) .interval ( Duration:: from_secs( 15 ) ) .text ( " " ) ) .into_response ( ) ;
219
r.headers_mut ( ) .insert ( " x-accel-buffering" , " no" .parse ( ) .unwrap ( ) ) ;
220
r
221
}
222
037c45d8 Andy McCall 2 days ago ↶
223
224
571572f5 Andy McCall 2 days ago ↶
225
pub async fn meta ( ctx : & Ctx) -> Json {
226
let mut m = serde_json:: Map:: new( ) ;
b044d909 Andy McCall 1 day ago ↶
227
if let Ok ( r) = spectra_core:: rules:: compile( ctx.daemon.ledger.pool ( ) , crate :: tools:: project_of( & ctx.caller.principal) ) .await {
037c45d8 Andy McCall 2 days ago ↶
228
m.insert ( " spectra/rules_version" .into ( ) , json! ( r.version) ) ;
229
}
571572f5 Andy McCall 2 days ago ↶
230
if let Some ( s) = & ctx.session {
231
m.insert ( " spectra/session" .into ( ) , json! ( s) ) ;
232
}
233
if let Ok ( n) = spectra_core:: conversation:: unread( & ctx.daemon.ledger, & ctx.caller.principal.actor ( ) ) .await {
234
m.insert ( " spectra/inbox_unread" .into ( ) , json! ( n) ) ;
235
}
236
Json:: Object( m)
237
}
238
239
240
241
242
async fn owner_events (
243
State( state ) : State< AppState> ,
244
ConnectInfo( peer ) : ConnectInfo< PeerInfo> ,
245
headers : HeaderMap,
246
body : Bytes,
247
) -> Response {
248
let caller = match authenticate ( & state, peer, & headers, " POST /api/v1/ledger/events" , & body) .await {
249
Ok ( c) => c,
250
Err ( r) => return r,
251
} ;
252
if ! matches! ( caller.principal, Principal:: Owner { .. } ) {
253
return ( StatusCode:: FORBIDDEN , axum:: Json( json! ( { " title" : " only the owner hands events to the daemon" } ) ) ) .into_response ( ) ;
254
}
255
let events: Vec< WireEvent> = match serde_json:: from_slice( & body) {
256
Ok ( v) => v,
257
Err ( e) => return ( StatusCode:: BAD_REQUEST , axum:: Json( json! ( { " title" : format! ( " not a list of events: {e} " ) } ) ) ) .into_response ( ) ,
258
} ;
259
match state.daemon.accept_owner_events ( events) .await {
260
Ok ( seqs) => axum:: Json( json! ( { " appended" : seqs } ) ) .into_response ( ) ,
261
Err ( e) => ( StatusCode:: UNPROCESSABLE_ENTITY , axum:: Json( json! ( { " title" : e.to_string ( ) } ) ) ) .into_response ( ) ,
262
}
263
}