11/**
2- * Ramp service implementation. Two authentication pathways, both production:
2+ * Ramp service implementation (browser / AI agent-key pathway, production only).
33 *
4- * 1. Browser login (`latchkey auth browser ramp`): the OAuth 2.0
5- * authorization-code + PKCE flow against Ramp's public client (a fixed client
6- * ID, no secret). The hosted consent screen (auth_level=auto) mints an "AI
7- * agent key"; latchkey catches the loopback callback, exchanges the code for a
8- * bearer + refresh token at `.../developer/v1/token/pkce`, and stores them as
9- * OAuthCredentials (auto-refreshed). Agent keys use the agent-tools endpoints.
4+ * `latchkey auth browser ramp` runs the OAuth 2.0 authorization-code + PKCE flow
5+ * against Ramp's public client (a fixed client ID, no secret). The hosted consent
6+ * screen (auth_level=auto) mints an "AI agent key"; latchkey catches the loopback
7+ * callback, exchanges the code for a bearer + refresh token at
8+ * `.../developer/v1/token/pkce`, and stores them as OAuthCredentials (auto-refreshed).
109 *
11- * 2. API client (`latchkey auth set-nocurl ramp <client_id> <client_secret>
12- * <scope> ...`): the OAuth 2.0 client_credentials grant for single-org access.
13- * The user registers an API client in the Ramp dashboard, enables scopes, and
14- * gives latchkey the client ID/secret and those scopes; latchkey mints/refreshes
15- * a bearer token for exactly those scopes. No refresh token in this grant.
16- *
17- * Every API call targets https://api.ramp.com/developer/v1.
10+ * Agent keys use the agent-tools endpoints -- POST https://api.ramp.com/developer/v1/
11+ * agent-tools/<tool> with a {"rationale": ...} body (they are auth-level barred from
12+ * the standard REST endpoints). Spec: https://api.ramp.com/v1/public/agent-tools/spec/.
1813 */
1914
2015import { randomUUID } from 'node:crypto' ;
2116import type { Browser , BrowserContext , Response } from 'playwright' ;
22- import { z } from 'zod' ;
23- import {
24- ApiCredentials ,
25- ApiCredentialStatus ,
26- ApiCredentialsUsageError ,
27- OAuthCredentials ,
28- } from '../apiCredentials/base.js' ;
29- import { runCaptured } from '../curl.js' ;
17+ import { ApiCredentials , ApiCredentialStatus , OAuthCredentials } from '../apiCredentials/base.js' ;
3018import {
3119 exchangeCodeForTokens ,
3220 generateCodeChallenge ,
3321 generateCodeVerifier ,
3422 refreshAccessToken ,
3523 startOAuthCallbackServer ,
3624} from '../oauthUtils.js' ;
37- import {
38- isBrowserClosedError ,
39- LoginCancelledError ,
40- NoCurlCredentialsNotSupportedError ,
41- Service ,
42- ServiceSession ,
43- } from './core/base.js' ;
44-
45- /** Client_credentials token endpoint. */
46- const RAMP_TOKEN_ENDPOINT = 'https://api.ramp.com/developer/v1/token' ;
47-
48- /**
49- * Treat a token as expired this long before its real expiry, so it is never
50- * used right at the edge of its lifetime.
51- */
52- const EXPIRY_BUFFER_MS = 60_000 ;
53-
54- interface RampTokenResponse {
55- access_token : string ;
56- expires_in : number ;
57- }
58-
59- /**
60- * Mint a fresh access token from Ramp using the client_credentials grant.
61- * Returns null if the request fails or the response is malformed.
62- */
63- function requestRampToken (
64- clientId : string ,
65- clientSecret : string ,
66- scope : string
67- ) : RampTokenResponse | null {
68- const basicAuth = Buffer . from ( `${ clientId } :${ clientSecret } ` ) . toString ( 'base64' ) ;
69- const body = new URLSearchParams ( {
70- grant_type : 'client_credentials' ,
71- scope,
72- } ) . toString ( ) ;
73-
74- const result = runCaptured (
75- [
76- '-s' ,
77- '-X' ,
78- 'POST' ,
79- '-H' ,
80- `Authorization: Basic ${ basicAuth } ` ,
81- '-H' ,
82- 'Content-Type: application/x-www-form-urlencoded' ,
83- '-d' ,
84- body ,
85- RAMP_TOKEN_ENDPOINT ,
86- ] ,
87- 30
88- ) ;
89-
90- if ( result . returncode !== 0 ) {
91- return null ;
92- }
93-
94- try {
95- const response = JSON . parse ( result . stdout ) as Partial < RampTokenResponse > ;
96- if ( typeof response . access_token !== 'string' || typeof response . expires_in !== 'number' ) {
97- return null ;
98- }
99- return { access_token : response . access_token , expires_in : response . expires_in } ;
100- } catch {
101- return null ;
102- }
103- }
25+ import { isBrowserClosedError , LoginCancelledError , Service , ServiceSession } from './core/base.js' ;
10426
10527/** Ramp's public OAuth client (PKCE, no secret), from ramp-cli. */
10628const RAMP_OAUTH_CLIENT_ID = 'ramp_id_6pKvd0IR3d8Kuzp82SV6YgpVCZOlz68Px6s3wVsr' ;
10729
10830/** Hosted authorize endpoint (where the user signs in / approves the agent key). */
10931const RAMP_AUTHORIZE_URL = 'https://app.ramp.com/v1/authorize' ;
11032
111- /** PKCE token endpoint (code exchange + refresh). Distinct from the `/token` one. */
33+ /** PKCE token endpoint (code exchange + refresh). */
11234const RAMP_PKCE_TOKEN_ENDPOINT = 'https://api.ramp.com/developer/v1/token/pkce' ;
11335
11436/** Loopback callback path; matches ramp-cli's `/callback`. */
@@ -265,141 +187,25 @@ class RampOAuthServiceSession extends ServiceSession {
265187 }
266188}
267189
268- /**
269- * Ramp OAuth client_credentials credentials.
270- *
271- * Stores the client ID/secret and the exact scopes the app was granted (used to
272- * mint tokens) plus the most recently minted access token. The token is injected
273- * as `Authorization: Bearer`.
274- */
275- export const RampCredentialsSchema = z . object ( {
276- objectType : z . literal ( 'ramp' ) ,
277- clientId : z . string ( ) ,
278- clientSecret : z . string ( ) ,
279- scope : z . string ( ) ,
280- accessToken : z . string ( ) . optional ( ) ,
281- accessTokenExpiresAt : z . string ( ) . optional ( ) ,
282- } ) ;
283-
284- export type RampCredentialsData = z . infer < typeof RampCredentialsSchema > ;
285-
286- export class RampCredentials implements ApiCredentials {
287- readonly objectType = 'ramp' as const ;
288- readonly clientId : string ;
289- readonly clientSecret : string ;
290- readonly scope : string ;
291- readonly accessToken ?: string ;
292- readonly accessTokenExpiresAt ?: string ;
293-
294- constructor (
295- clientId : string ,
296- clientSecret : string ,
297- scope : string ,
298- accessToken ?: string ,
299- accessTokenExpiresAt ?: string
300- ) {
301- this . clientId = clientId ;
302- this . clientSecret = clientSecret ;
303- this . scope = scope ;
304- this . accessToken = accessToken ;
305- this . accessTokenExpiresAt = accessTokenExpiresAt ;
306- }
307-
308- injectIntoCurlCall ( curlArguments : readonly string [ ] ) : Promise < readonly string [ ] > {
309- if ( this . accessToken === undefined ) {
310- throw new ApiCredentialsUsageError (
311- 'Ramp credentials have no access token yet. A token is minted automatically on use; ' +
312- 'if you see this, re-run the command or re-set the credentials.'
313- ) ;
314- }
315- return Promise . resolve ( [ '-H' , `Authorization: Bearer ${ this . accessToken } ` , ...curlArguments ] ) ;
316- }
317-
318- isExpired ( ) : boolean | undefined {
319- // No token yet (only client ID/secret stored): report expired so the refresh
320- // path mints the first token before the request goes out.
321- if ( this . accessToken === undefined ) {
322- return true ;
323- }
324- if ( this . accessTokenExpiresAt === undefined ) {
325- return undefined ;
326- }
327- return Date . now ( ) >= new Date ( this . accessTokenExpiresAt ) . getTime ( ) - EXPIRY_BUFFER_MS ;
328- }
329-
330- /** Return a copy carrying a freshly minted access token. */
331- withToken ( accessToken : string , accessTokenExpiresAt : string ) : RampCredentials {
332- return new RampCredentials (
333- this . clientId ,
334- this . clientSecret ,
335- this . scope ,
336- accessToken ,
337- accessTokenExpiresAt
338- ) ;
339- }
340-
341- toJSON ( ) : RampCredentialsData {
342- return {
343- objectType : this . objectType ,
344- clientId : this . clientId ,
345- clientSecret : this . clientSecret ,
346- scope : this . scope ,
347- accessToken : this . accessToken ,
348- accessTokenExpiresAt : this . accessTokenExpiresAt ,
349- } ;
350- }
351-
352- static fromJSON ( data : RampCredentialsData ) : RampCredentials {
353- return new RampCredentials (
354- data . clientId ,
355- data . clientSecret ,
356- data . scope ,
357- data . accessToken ,
358- data . accessTokenExpiresAt
359- ) ;
360- }
361- }
362-
363- class RampCredentialError extends NoCurlCredentialsNotSupportedError {
364- constructor ( message : string ) {
365- super ( 'ramp' ) ;
366- this . message = message ;
367- this . name = 'RampCredentialError' ;
368- }
369- }
370-
371190export class Ramp extends Service {
372191 readonly name = 'ramp' ;
373192 readonly displayName = 'Ramp' ;
374193 readonly baseApiUrls = [ 'https://api.ramp.com/' ] as const ;
375194 readonly loginUrl = 'https://app.ramp.com/' ;
376195 readonly info =
377- 'Ramp developer API. Agent-tools OpenAPI spec: https://api.ramp.com/v1/public/agent-tools/spec/. ' +
378- 'Sign in with `latchkey auth browser ramp` to mint an AI agent key; agent keys call ' +
379- 'POST https://api.ramp.com/developer/v1/agent-tools/<tool> with a JSON {"rationale":"..."} body. ' +
380- '(An API client can also be stored with `latchkey auth set-nocurl ramp <client_id> <client_secret> <scope> ...`.)' ;
196+ 'Ramp developer API for AI agents. Agent-tools OpenAPI spec: https://api.ramp.com/v1/public/agent-tools/spec/. ' +
197+ 'Sign in with `latchkey auth browser ramp` to mint an AI agent key; calls are ' +
198+ 'POST https://api.ramp.com/developer/v1/agent-tools/<tool> with a JSON {"rationale":"..."} body.' ;
381199
382- // Unused: credentials are validated by minting a token (see checkApiCredentials),
383- // which is scope-independent. Kept for documentation of the simplest read call.
200+ // Unused: browser-login credentials are validated by holding/refreshing a live
201+ // token (see checkApiCredentials), not by hitting a resource endpoint. Only present
202+ // because the base class declares it abstract.
384203 readonly credentialCheckCurlArguments = [
385- 'https://api.ramp.com/developer/v1/transactions ' ,
204+ 'https://api.ramp.com/developer/v1/agent-tools/search-help-center-snippets ' ,
386205 ] as const ;
387206
388207 setCredentialsExample ( serviceName : string ) : string {
389- return `latchkey auth set-nocurl ${ serviceName } <client_id> <client_secret> <scope> [scope ...]` ;
390- }
391-
392- override getCredentialsNoCurl ( arguments_ : readonly string [ ] ) : ApiCredentials {
393- const positional = arguments_ . filter ( ( argument ) => argument !== '' ) ;
394- const [ clientId , clientSecret , ...scopes ] = positional ;
395- if ( clientId === undefined || clientSecret === undefined || scopes . length === 0 ) {
396- throw new RampCredentialError (
397- 'Expected: <client_id> <client_secret> <scope> [scope ...]\n' +
398- 'Pass the scopes you enabled on the Ramp app (Settings -> Developer), space-separated.\n' +
399- 'Example: latchkey auth set-nocurl ramp <client_id> <client_secret> transactions:read users:read'
400- ) ;
401- }
402- return new RampCredentials ( clientId , clientSecret , scopes . join ( ' ' ) ) ;
208+ return `latchkey auth browser ${ serviceName } ` ;
403209 }
404210
405211 /**
@@ -411,29 +217,11 @@ export class Ramp extends Service {
411217 }
412218
413219 override refreshCredentials ( apiCredentials : ApiCredentials ) : Promise < ApiCredentials | null > {
414- // Browser-login credentials: refresh the PKCE access token with the (rotating)
415- // refresh token against the `/token/pkce` endpoint.
416- if ( apiCredentials instanceof OAuthCredentials ) {
417- return this . refreshOAuthCredentials ( apiCredentials ) ;
418- }
419- if ( ! ( apiCredentials instanceof RampCredentials ) ) {
420- return Promise . resolve ( null ) ;
421- }
422- const token = requestRampToken (
423- apiCredentials . clientId ,
424- apiCredentials . clientSecret ,
425- apiCredentials . scope
426- ) ;
427- if ( token === null ) {
220+ // Refresh the PKCE access token with the (rotating) refresh token against the
221+ // `/token/pkce` endpoint, mirroring ramp-cli.
222+ if ( ! ( apiCredentials instanceof OAuthCredentials ) ) {
428223 return Promise . resolve ( null ) ;
429224 }
430- const accessTokenExpiresAt = new Date ( Date . now ( ) + token . expires_in * 1000 ) . toISOString ( ) ;
431- return Promise . resolve ( apiCredentials . withToken ( token . access_token , accessTokenExpiresAt ) ) ;
432- }
433-
434- private refreshOAuthCredentials (
435- apiCredentials : OAuthCredentials
436- ) : Promise < ApiCredentials | null > {
437225 if ( apiCredentials . refreshToken === undefined || apiCredentials . refreshToken === '' ) {
438226 return Promise . resolve ( null ) ;
439227 }
@@ -461,34 +249,14 @@ export class Ramp extends Service {
461249 }
462250
463251 /**
464- * Validate credentials by confirming a token can be minted/refreshed rather than
465- * by hitting a specific resource endpoint. Ramp has no scope-free endpoint, so a
466- * resource check would force every user to grant one particular scope; minting is
467- * the scope-independent source of truth ("can these credentials obtain a token?").
252+ * Validate credentials by confirming a live token is held (refreshing first if
253+ * expired) rather than by hitting a resource endpoint -- Ramp has no scope-free
254+ * endpoint, so a resource check would force the user to grant a particular scope.
468255 */
469256 override async checkApiCredentials ( apiCredentials : ApiCredentials ) : Promise < ApiCredentialStatus > {
470- if ( apiCredentials instanceof OAuthCredentials ) {
471- return this . checkOAuthCredentials ( apiCredentials ) ;
472- }
473- if ( ! ( apiCredentials instanceof RampCredentials ) ) {
257+ if ( ! ( apiCredentials instanceof OAuthCredentials ) ) {
474258 return ApiCredentialStatus . Missing ;
475259 }
476- let credentials : RampCredentials | null = apiCredentials ;
477- if ( credentials . isExpired ( ) === true ) {
478- const refreshed = await this . refreshCredentials ( apiCredentials ) ;
479- credentials = refreshed instanceof RampCredentials ? refreshed : null ;
480- }
481- if ( credentials ?. accessToken === undefined ) {
482- return ApiCredentialStatus . Invalid ;
483- }
484- return credentials . isExpired ( ) === true
485- ? ApiCredentialStatus . Invalid
486- : ApiCredentialStatus . Valid ;
487- }
488-
489- private async checkOAuthCredentials (
490- apiCredentials : OAuthCredentials
491- ) : Promise < ApiCredentialStatus > {
492260 let credentials : OAuthCredentials | null = apiCredentials ;
493261 if ( credentials . isExpired ( ) === true ) {
494262 const refreshed = await this . refreshCredentials ( apiCredentials ) ;
0 commit comments