Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ jobs:
- name: Typecheck, test, and build
run: pnpm check

- name: Test the standalone testnet runner
run: |
npm ci --prefix scripts/runner --ignore-scripts --no-audit --no-fund
npm test --prefix scripts/runner
bash -n scripts/runner/roque_run

solidity:
name: Solidity checks
runs-on: ubuntu-24.04
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ pnpm relayer # the relayer and API
pnpm keeper # the keeper loop that settles resting orders
```

For randomized testnet swaps against the deployed app, run `~/rialo/roque_run`.
Use `~/rialo/roque_run --dry-run` for a read-only balance check and sample plans.
The runner uses Autonomous Confirm/Direct exclusively, with a hard limit of
100 Sepolia submissions including setup. Configuration, Latch routing, logs and
recovery are documented in [the runner guide](scripts/runner/README.md).

## Testing

The contracts carry a full Foundry suite, and it is meant to stay green.
Expand Down
23 changes: 23 additions & 0 deletions apps/relayer/src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type {FastifyInstance,FastifyRequest} from "fastify";

// Log constructed records only. OAuth URLs, bodies, headers and Error objects
// can contain credentials, authorization codes and PKCE material.
const reasons=["success","rejected","invalid_client_metadata","invalid_redirect_uri","invalid_scope","invalid_request","invalid_grant","invalid_client","wallet_required","invalid_target","rate_limited","internal_error"] as const;
type Reason=typeof reasons[number];
const details=new WeakMap<FastifyRequest,{reason:Reason;invalidFields?:string[]}>();
const metadataFields=["client_name","redirect_uris","grant_types","response_types","token_endpoint_auth_method","scope"];
export function diagnosticReason(req:FastifyRequest,reason:Reason,invalidFields?:string[]){
details.set(req,{reason,...(invalidFields?{invalidFields:metadataFields.filter(field=>invalidFields.includes(field))}:{})});
}
export function registerDiagnostics(app:FastifyInstance){
app.addHook("onResponse",async(req,reply)=>{
const oauth=req.url.startsWith("/oauth/");
const publicFailure=reply.statusCode>=400&&(req.url.startsWith("/v1/")||req.url.split("?",1)[0]==="/mcp");
if(!oauth&&!publicFailure)return;
const record=details.get(req);
// Fastify route templates exclude both query values and dynamic path values.
const route=req.routeOptions.url??(oauth?"/oauth/*":"/public/*");
const reason=record?.reason??(reply.statusCode>=500?"internal_error":reply.statusCode===429?"rate_limited":reply.statusCode>=400?"rejected":"success");
console.info(JSON.stringify({event:oauth?"oauth_request":"public_api_error",timestamp:new Date().toISOString(),requestId:req.id,route,method:req.method,status:reply.statusCode,reason,...(record?.invalidFields?{invalidFields:record.invalidFields}:{})}));
});
}
34 changes: 28 additions & 6 deletions apps/relayer/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {authOrigin} from "@roque/core/env";
import {API_SCOPES} from "@roque/core/public-api";
import {authenticatedOwner,tokenHash,revokeSession} from "../../../packages/core/src/auth.js";
import {admit} from "../../../packages/core/src/admission.js";
import {diagnosticReason} from "./diagnostics.js";
export function mcpResource(){const url=process.env.MCP_RESOURCE_URL??`${authOrigin()}/mcp`;return new URL(url).href;}
const clientModel=async(id:string)=>{
const rows=await q<{id:string;redirects:string[];scopes:string[]}>("SELECT id,redirects,scopes FROM api_clients WHERE id=$1 AND revoked_at IS NULL",[id]);
Expand Down Expand Up @@ -39,6 +40,16 @@ export const oauth=new OAuth2Server({accessTokenLifetime:3600,authorizationCodeL
verifyScope:async(token,scope)=>scope.every(s=>token.scope?.includes(s)),
}});
function bearer(header:string|undefined){return header?.match(/^Bearer ([A-Za-z0-9_-]+)$/u)?.[1];}
const scopeString=z.string().min(1).max(300).regex(/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/u);
const registration=z.object({
client_name:z.string().min(1).max(100).optional(),
redirect_uris:z.array(z.string().url().max(2000)).min(1).max(5),
grant_types:z.array(z.literal("authorization_code")).length(1).optional(),
response_types:z.array(z.literal("code")).length(1).optional(),
token_endpoint_auth_method:z.literal("none").optional(),
scope:scopeString.optional(),
// RFC 7591 requires ignoring client metadata we do not understand.
}).strip();
const authQuery=z.object({client_id:z.string().max(128),redirect_uri:z.string().url().max(2000),response_type:z.literal("code"),
code_challenge:z.string().regex(/^[A-Za-z0-9_-]{43}$/u),code_challenge_method:z.literal("S256"),state:z.string().min(16).max(500),
resource:z.string().url(),scope:z.string().max(300).optional()}).strict();
Expand All @@ -49,16 +60,27 @@ export async function registerOAuth(app:FastifyInstance){
app.get("/.well-known/oauth-authorization-server",async()=>({issuer,authorization_endpoint:`${issuer}/oauth/authorize`,token_endpoint:`${issuer}/oauth/token`,registration_endpoint:`${issuer}/oauth/register`,revocation_endpoint:`${issuer}/oauth/revoke`,response_types_supported:["code"],grant_types_supported:["authorization_code"],token_endpoint_auth_methods_supported:["none"],code_challenge_methods_supported:["S256"],scopes_supported:[...API_SCOPES]}));
app.post("/oauth/register",async(req,reply)=>{
await admit(`oauth-register:${req.ip}`,10);
const input=z.object({client_name:z.string().min(1).max(100),redirect_uris:z.array(z.string().url().max(2000)).min(1).max(5),grant_types:z.array(z.literal("authorization_code")).optional(),response_types:z.array(z.literal("code")).optional(),token_endpoint_auth_method:z.literal("none").optional()}).strict().parse(req.body);
const allowed=(process.env.OAUTH_ALLOWED_REDIRECTS??"").split(",").filter(Boolean);
if(input.redirect_uris.some(u=>!allowed.includes(u))) return reply.status(400).send({error:"invalid_redirect_uri"});
const id=randomUUID();await q("INSERT INTO api_clients(id,key_hash,name,scopes,redirects) VALUES($1,$2,$3,$4,$5)",[id,tokenHash(randomBytes(32).toString("base64url")),input.client_name,[...API_SCOPES],input.redirect_uris]);
return reply.status(201).send({client_id:id,client_name:input.client_name,redirect_uris:input.redirect_uris,grant_types:["authorization_code"],response_types:["code"],token_endpoint_auth_method:"none"});
const parsed=registration.safeParse(req.body);
if(!parsed.success){
diagnosticReason(req,"invalid_client_metadata",parsed.error.issues.map(issue=>String(issue.path[0])));
return reply.status(400).send({error:"invalid_client_metadata",error_description:"Invalid or unsupported registration fields."});
}
const input=parsed.data,scopes=input.scope?[...new Set(input.scope.split(" "))]:[...API_SCOPES];
if(scopes.some(scope=>!API_SCOPES.includes(scope as typeof API_SCOPES[number]))){
diagnosticReason(req,"invalid_client_metadata",["scope"]);
return reply.status(400).send({error:"invalid_client_metadata",error_description:"Requested scope is not supported."});
}
const allowed=(process.env.OAUTH_ALLOWED_REDIRECTS??"").split(",").map(value=>value.trim()).filter(Boolean);
if(input.redirect_uris.some(u=>!allowed.includes(u))){diagnosticReason(req,"invalid_redirect_uri");return reply.status(400).send({error:"invalid_redirect_uri",error_description:"Every redirect URI must be explicitly allowlisted."});}
const id=randomUUID(),name=input.client_name??`MCP client ${id}`;
await q("INSERT INTO api_clients(id,key_hash,name,scopes,redirects) VALUES($1,$2,$3,$4,$5)",[id,tokenHash(randomBytes(32).toString("base64url")),name,scopes,input.redirect_uris]);
return reply.status(201).send({client_id:id,client_name:name,redirect_uris:input.redirect_uris,grant_types:["authorization_code"],response_types:["code"],token_endpoint_auth_method:"none",scope:scopes.join(" ")});
});
app.get("/oauth/authorize",async(req,reply)=>{
await admit(`oauth-authorize:${req.ip}`,20);const query=authQuery.parse(req.query);
const client=await clientModel(query.client_id);if(!client||!client.redirectUris.includes(query.redirect_uri)||query.resource!==mcpResource())return reply.status(400).send({error:"invalid_request"});
query.scope=query.scope??API_SCOPES.join(" ");
query.scope=query.scope??client.scope.join(" ");
if(!scopeString.safeParse(query.scope).success||query.scope.split(" ").some(scope=>!(client.scope as string[]).includes(scope))){diagnosticReason(req,"invalid_scope");return reply.status(400).send({error:"invalid_scope",error_description:"Requested scope is not permitted for this client."});}
const requestId=randomUUID();await q("INSERT INTO oauth_requests(id,query,expires_at) VALUES($1,$2::jsonb,now()+interval '5 minutes')",[requestId,JSON.stringify(query)]);
return reply.redirect(`${authOrigin()}/connect?request=${requestId}`);
});
Expand Down
17 changes: 14 additions & 3 deletions apps/relayer/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
*/

import {pathToFileURL} from "node:url";
import {randomUUID} from "node:crypto";
import OAuth2Server from "@node-oauth/oauth2-server";
import {registerOAuth} from "./oauth.js";
import {registerDiagnostics,diagnosticReason} from "./diagnostics.js";
import Fastify from "fastify";
import {z,ZodError} from "zod";
import {admitTransport,admit} from "../../../packages/core/src/admission.js";
Expand Down Expand Up @@ -61,16 +63,25 @@ import {
import { startWorkers } from "./workers.js";

export async function createServer() {
const app = Fastify({logger:false,bodyLimit:16384});
const app = Fastify({logger:false,bodyLimit:16384,requestIdHeader:false,genReqId:()=>randomUUID()});
registerDiagnostics(app);
app.addHook("onRequest",async req=>{admitTransport(req.ip);await admit("global:http",600);});
app.addHook("onSend",async(_req,reply)=>{reply.header("Cache-Control","no-store");reply.header("X-Content-Type-Options","nosniff");});
app.addHook("onSend",async(req,reply)=>{reply.header("Cache-Control","no-store");reply.header("X-Content-Type-Options","nosniff");reply.header("X-Request-Id",req.id);});
app.addContentTypeParser("application/x-www-form-urlencoded",{parseAs:"string"},(_req,body,done)=>{
const parsed=Object.fromEntries(new URLSearchParams(String(body)));done(null,parsed);
});
const allowedOrigins=(process.env.API_ALLOWED_ORIGINS??"http://localhost:3000").split(",");
await app.register(cors,{origin:allowedOrigins,allowedHeaders:["content-type","authorization","x-api-key","idempotency-key"],methods:["GET","POST","DELETE","OPTIONS"]});
await app.register(cors,{origin:allowedOrigins,allowedHeaders:["content-type","authorization","x-api-key","idempotency-key"],exposedHeaders:["x-request-id"],methods:["GET","POST","DELETE","OPTIONS"]});

app.setErrorHandler((err, _req, reply) => {
if(_req.url.split("?",1)[0]==="/oauth/register"){
if((err as {status?:number}).status===429){diagnosticReason(_req,"rate_limited");reply.header("Retry-After","60").status(429).send({error:"temporarily_unavailable",error_description:"Registration quota exceeded. Retry after one minute."});return;}
const transport=err as {code?:string;statusCode?:number};
if(err instanceof ZodError||(transport.code?.startsWith("FST_ERR_")&&transport.statusCode&&transport.statusCode>=400&&transport.statusCode<500)){
diagnosticReason(_req,"invalid_client_metadata");reply.status(transport.statusCode===413?413:400).send({error:"invalid_client_metadata",error_description:"Invalid registration fields, body or content type."});return;
}
diagnosticReason(_req,"internal_error");reply.status(500).send({error:"server_error",error_description:"Registration service unavailable."});return;
}
if(err instanceof OAuth2Server.OAuthError){reply.status(err.code).send({error:err.name,error_description:err.code<500?err.message:"Authorization service unavailable."});return;}
if(err instanceof ZodError) {reply.status(400).send({error:{code:"INVALID_REQUEST",message:"Invalid request fields.",requestId:_req.id}});return;}
if(err instanceof v1.PublicApiError) {reply.status(err.status).send({error:{code:err.code,message:err.message,requestId:_req.id}});return;}
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/app/_public-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "server-only";
import {randomUUID} from "node:crypto";
import {after} from "next/server";
import {createMcpServer} from "@roque/mcp";
import {interpretationTick} from "@roque/core/public-api";
Expand All @@ -12,7 +13,13 @@ export async function dispatchPublic(req:Request,path?:string){
let payload:string|undefined;
if(!["GET","HEAD"].includes(req.method)){
const reader=req.body?.getReader();const chunks:Uint8Array[]=[];let size=0;
if(reader)try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.byteLength;if(size>16384){await reader.cancel();return new Response(JSON.stringify({error:{code:"BODY_TOO_LARGE",message:"Request exceeds 16 KiB."}}),{status:413,headers:{"content-type":"application/json"}});}chunks.push(value);}}finally{reader.releaseLock();}
if(reader)try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.byteLength;if(size>16384){
await reader.cancel();
const registration=url.pathname==="/oauth/register",requestId=randomUUID();
console.info(JSON.stringify({event:registration?"oauth_request":"public_api_error",timestamp:new Date().toISOString(),requestId,route:registration?"/oauth/register":"/public/*",method:req.method,status:413,reason:registration?"invalid_client_metadata":"rejected"}));
const body=registration?{error:"invalid_client_metadata",error_description:"Registration body exceeds 16 KiB."}:{error:{code:"BODY_TOO_LARGE",message:"Request exceeds 16 KiB.",requestId}};
return new Response(JSON.stringify(body),{status:413,headers:{"content-type":"application/json","cache-control":"no-store","x-content-type-options":"nosniff","x-request-id":requestId}});
}chunks.push(value);}}finally{reader.releaseLock();}
if(size>0)payload=Buffer.concat(chunks).toString("utf8");
}
server??=createMcpServer().catch(err=>{server=undefined;throw err;});
Expand Down
Loading